mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 05:56:17 -10:00
Compare commits
357
Commits
No files matched your search
@@ -138,7 +138,23 @@ Final rule: conclude only when evidence shows missing = 0.
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
PUBLISH WORKFLOW (reference)
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Step 1 — Publish praisonaiagents (Core SDK):
|
||||
GitHub Actions (recommended):
|
||||
GitHub → Actions → "PyPI Release" → Run workflow → bump: patch
|
||||
Requires repo secrets: PYPI_TOKEN, GH_TOKEN
|
||||
Requires GitHub Environment "pypi" with required reviewers (Settings → Environments)
|
||||
|
||||
Manual — Publish aiui (PraisonAIUI):
|
||||
cd /Users/praison/PraisonAIUI
|
||||
# Bump version in pyproject.toml and src/praisonaiui/__version__.py
|
||||
rm -rf dist && uv lock && uv build && uv publish --token "$PYPI_TOKEN"
|
||||
git add pyproject.toml uv.lock src/praisonaiui/__version__.py
|
||||
git commit -m "Release vX.Y.Z" && git tag vX.Y.Z && git push && git push --tags
|
||||
gh release create vX.Y.Z --title "PraisonAIUI vX.Y.Z" --notes "Release vX.Y.Z" --latest
|
||||
|
||||
PyPI package name: aiui
|
||||
Verify: pip index versions aiui | head -1
|
||||
|
||||
PraisonAI stack (separate repo — publish SDK before wrapper):
|
||||
cd /Users/praison/praisonai-package/src/praisonai-agents
|
||||
praisonai publish pypi
|
||||
# Uses uv internally: uv lock → uv build → uv publish
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
name: "Claude Code Action"
|
||||
description: "Run Claude Code in GitHub Actions workflows"
|
||||
|
||||
inputs:
|
||||
anthropic_api_key:
|
||||
description: "Anthropic API key"
|
||||
required: false
|
||||
claude_code_oauth_token:
|
||||
description: "Claude Code OAuth Token for Claude Pro"
|
||||
required: false
|
||||
github_token:
|
||||
description: "GitHub token for Claude to operate with"
|
||||
required: false
|
||||
default: ${{ github.token }}
|
||||
trigger_phrase:
|
||||
description: "The trigger phrase to look for in comments, issue/PR bodies, and issue titles"
|
||||
required: false
|
||||
default: "@claude"
|
||||
assignee_trigger:
|
||||
description: "The assignee username that triggers the action (e.g. @claude). Only used for issue assignment"
|
||||
required: false
|
||||
max_turns:
|
||||
description: "Maximum number of conversation turns Claude can take"
|
||||
required: false
|
||||
timeout_minutes:
|
||||
description: "Timeout in minutes for execution"
|
||||
required: false
|
||||
default: "30"
|
||||
model:
|
||||
description: "Model to use (provider-specific format required for Bedrock/Vertex)"
|
||||
required: false
|
||||
use_bedrock:
|
||||
description: "Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API"
|
||||
required: false
|
||||
default: "false"
|
||||
use_vertex:
|
||||
description: "Use Google Vertex AI with OIDC authentication instead of direct Anthropic API"
|
||||
required: false
|
||||
default: "false"
|
||||
allowed_tools:
|
||||
description: "Additional tools for Claude to use (the base GitHub tools will always be included)"
|
||||
required: false
|
||||
default: ""
|
||||
disallowed_tools:
|
||||
description: "Tools that Claude should never use"
|
||||
required: false
|
||||
default: ""
|
||||
custom_instructions:
|
||||
description: "Additional custom instructions to include in the prompt for Claude"
|
||||
required: false
|
||||
default: ""
|
||||
mcp_config:
|
||||
description: "Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers"
|
||||
required: false
|
||||
default: ""
|
||||
claude_env:
|
||||
description: "Custom environment variables to pass to Claude Code execution (YAML format)"
|
||||
required: false
|
||||
default: ""
|
||||
direct_prompt:
|
||||
description: "Direct prompt for Claude to execute automatically without needing a trigger (for automated workflows)"
|
||||
required: false
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Install Claude Code
|
||||
shell: bash
|
||||
run: npm install -g @anthropic-ai/claude-code
|
||||
|
||||
- name: Install GitHub MCP Server
|
||||
shell: bash
|
||||
run: |
|
||||
claude mcp add-json github '{
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"run",
|
||||
"-i",
|
||||
"--rm",
|
||||
"-e",
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN",
|
||||
"ghcr.io/github/github-mcp-server:sha-ff3036d"
|
||||
],
|
||||
"env": {
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "${{ inputs.github_token }}"
|
||||
}
|
||||
}'
|
||||
|
||||
- name: Extract GitHub Context and Create Prompt
|
||||
shell: bash
|
||||
id: prepare_context
|
||||
env:
|
||||
# Pass user-controlled inputs via environment variables to prevent script injection (GHSL-2025-093)
|
||||
EVENT_COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
EVENT_ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
EVENT_ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
EVENT_PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
EVENT_REVIEW_BODY: ${{ github.event.review.body }}
|
||||
EVENT_ASSIGNEE_LOGIN: ${{ github.event.assignee.login }}
|
||||
INPUT_TRIGGER_PHRASE: ${{ inputs.trigger_phrase }}
|
||||
INPUT_ASSIGNEE_TRIGGER: ${{ inputs.assignee_trigger }}
|
||||
INPUT_DIRECT_PROMPT: ${{ inputs.direct_prompt }}
|
||||
EVENT_ACTION: ${{ github.event.action }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
REPO_NAME: ${{ github.repository }}
|
||||
run: |
|
||||
echo "🔍 Extracting GitHub context from event: $EVENT_NAME"
|
||||
|
||||
# Function to check for trigger phrase
|
||||
check_trigger() {
|
||||
local text="$1"
|
||||
local trigger="$INPUT_TRIGGER_PHRASE"
|
||||
if [[ "$text" == *"$trigger"* ]]; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Extract context based on event type
|
||||
TRIGGER_FOUND="false"
|
||||
USER_REQUEST=""
|
||||
CONTEXT_INFO=""
|
||||
|
||||
case "$EVENT_NAME" in
|
||||
"issue_comment")
|
||||
COMMENT_BODY="$EVENT_COMMENT_BODY"
|
||||
ISSUE_TITLE="$EVENT_ISSUE_TITLE"
|
||||
ISSUE_NUMBER="$EVENT_ISSUE_NUMBER"
|
||||
|
||||
if check_trigger "$COMMENT_BODY"; then
|
||||
TRIGGER_FOUND="true"
|
||||
USER_REQUEST="$COMMENT_BODY"
|
||||
CONTEXT_INFO="Issue Comment on #$ISSUE_NUMBER: $ISSUE_TITLE"
|
||||
fi
|
||||
;;
|
||||
|
||||
"pull_request_review_comment")
|
||||
COMMENT_BODY="$EVENT_COMMENT_BODY"
|
||||
PR_TITLE="$EVENT_PR_TITLE"
|
||||
PR_NUMBER="$EVENT_PR_NUMBER"
|
||||
|
||||
if check_trigger "$COMMENT_BODY"; then
|
||||
TRIGGER_FOUND="true"
|
||||
USER_REQUEST="$COMMENT_BODY"
|
||||
CONTEXT_INFO="PR Comment on #$PR_NUMBER: $PR_TITLE"
|
||||
fi
|
||||
;;
|
||||
|
||||
"pull_request_review")
|
||||
REVIEW_BODY="$EVENT_REVIEW_BODY"
|
||||
PR_TITLE="$EVENT_PR_TITLE"
|
||||
PR_NUMBER="$EVENT_PR_NUMBER"
|
||||
|
||||
if check_trigger "$REVIEW_BODY"; then
|
||||
TRIGGER_FOUND="true"
|
||||
USER_REQUEST="$REVIEW_BODY"
|
||||
CONTEXT_INFO="PR Review on #$PR_NUMBER: $PR_TITLE"
|
||||
fi
|
||||
;;
|
||||
|
||||
"issues")
|
||||
ISSUE_BODY="$EVENT_ISSUE_BODY"
|
||||
ISSUE_TITLE="$EVENT_ISSUE_TITLE"
|
||||
ISSUE_NUMBER="$EVENT_ISSUE_NUMBER"
|
||||
|
||||
if check_trigger "$ISSUE_TITLE" || check_trigger "$ISSUE_BODY"; then
|
||||
TRIGGER_FOUND="true"
|
||||
USER_REQUEST="$ISSUE_BODY"
|
||||
CONTEXT_INFO="Issue #$ISSUE_NUMBER: $ISSUE_TITLE"
|
||||
elif [[ "$EVENT_ACTION" == "assigned" && -n "$INPUT_ASSIGNEE_TRIGGER" ]]; then
|
||||
ASSIGNEE="$EVENT_ASSIGNEE_LOGIN"
|
||||
if [[ "$ASSIGNEE" == "$INPUT_ASSIGNEE_TRIGGER" ]]; then
|
||||
TRIGGER_FOUND="true"
|
||||
USER_REQUEST="$ISSUE_BODY"
|
||||
CONTEXT_INFO="Issue #$ISSUE_NUMBER assigned to $ASSIGNEE: $ISSUE_TITLE"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# Check for direct prompt override
|
||||
if [[ -n "$INPUT_DIRECT_PROMPT" ]]; then
|
||||
TRIGGER_FOUND="true"
|
||||
USER_REQUEST="$INPUT_DIRECT_PROMPT"
|
||||
CONTEXT_INFO="Automated GitHub workflow"
|
||||
fi
|
||||
|
||||
if [[ "$TRIGGER_FOUND" != "true" ]]; then
|
||||
echo "❌ No trigger phrase found or direct prompt provided. Exiting gracefully."
|
||||
echo "SKIP_EXECUTION=true" >> $GITHUB_ENV
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "✅ Trigger found! Context: $CONTEXT_INFO"
|
||||
|
||||
# Create comprehensive prompt
|
||||
mkdir -p /tmp/claude-action
|
||||
cat > /tmp/claude-action/github-context-prompt.txt << EOF
|
||||
You are Claude Code, an AI assistant helping with GitHub workflows and code.
|
||||
|
||||
Repository: $REPO_NAME
|
||||
Context: $CONTEXT_INFO
|
||||
Event: $EVENT_NAME
|
||||
|
||||
User Request:
|
||||
$USER_REQUEST
|
||||
|
||||
Please analyze the request and provide helpful assistance.
|
||||
|
||||
CRITICAL INSTRUCTION: If your task involves modifying codebase files to add a feature or fix a bug:
|
||||
|
||||
STEP 0 — SETUP GIT IDENTITY:
|
||||
git config user.name "MervinPraison"
|
||||
git config user.email "[email protected]"
|
||||
|
||||
STEP 1 — READ GUIDELINES:
|
||||
MANDATORY: Before writing any code, you MUST carefully read "src/praisonai-agents/AGENTS.md" to understand this repository's extremely specific Architecture, Protocol-First design rules, and Agent-Centric Philosophy.
|
||||
|
||||
STEP 2 — ARCHITECTURE VALIDATION & ROUTING (MANDATORY before writing code):
|
||||
Before implementing anything, answer these questions:
|
||||
- CORE vs WRAPPER vs TOOLS ROUTING:
|
||||
1. Core SDK (praisonaiagents/): Only core protocols, base classes, decorators. No heavy implementations.
|
||||
2. Wrapper (praisonai/): CLI, heavy implementations, optional dependencies.
|
||||
3. Tools (PraisonAI-Tools): Specific integrations, external tools (e.g. SurrealDB, Slack), and community extensions MUST go to the `MervinPraison/PraisonAI-Tools` repository. If the feature is a tool, clone `https://github.com/MervinPraison/PraisonAI-Tools`, implement it there, and create the PR using `gh pr create -R MervinPraison/PraisonAI-Tools`.
|
||||
- Does it duplicate existing functionality? Check if Agent already supports this via existing params (reflection, planning, tools, hooks, memory).
|
||||
- Does it inherit from Agent properly? New agent types MUST inherit Agent, not wrap it with composition.
|
||||
- Does it add new dependencies? Only optional deps allowed, must be lazy-imported.
|
||||
- Will agent.py grow larger? If the change adds >50 lines to agent.py, find a way to extract instead.
|
||||
- Is there a name collision with existing exports in __init__.py?
|
||||
If ANY of these conceptual checks fail (excluding routing), add a comment to the issue explaining why and close it. Do NOT create a PR.
|
||||
|
||||
STEP 3 — IMPLEMENT:
|
||||
- Create a new git branch appropriately named (e.g. \`feature/issue-<number>\`)
|
||||
- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper
|
||||
- Keep changes small and backward-compatible
|
||||
|
||||
STEP 4 — TEST:
|
||||
- Run: cd src/praisonai-agents && PYTHONPATH=. python -m pytest tests/ -x -q --timeout=30
|
||||
- Ensure no regressions
|
||||
|
||||
STEP 5 — CREATE PR:
|
||||
- Commit with descriptive message, push the branch
|
||||
- Use the GitHub CLI (\`gh pr create --title "..." --body "Fixes #<number>"\`) to open a Pull Request
|
||||
CRITICAL: You MUST create the PR automatically using \`gh pr create\`. Do NOT just provide a link.
|
||||
|
||||
Respond naturally and helpfully to the user's request using the available tools.
|
||||
EOF
|
||||
|
||||
echo "PROMPT_FILE=/tmp/claude-action/github-context-prompt.txt" >> $GITHUB_ENV
|
||||
echo "SKIP_EXECUTION=false" >> $GITHUB_ENV
|
||||
|
||||
- name: Run Claude Code
|
||||
if: env.SKIP_EXECUTION != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "🚀 Running Claude Code with GitHub context..."
|
||||
|
||||
# Build command arguments
|
||||
CMD_ARGS=("-p" "--verbose" "--output-format" "stream-json")
|
||||
|
||||
# Add max turns if specified
|
||||
if [[ -n "${{ inputs.max_turns }}" ]]; then
|
||||
CMD_ARGS+=("--max-turns" "${{ inputs.max_turns }}")
|
||||
fi
|
||||
|
||||
# Add allowed tools (include GitHub tools by default)
|
||||
TOOLS="mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues,mcp__github__create_comment,Read,Write,Edit,Bash"
|
||||
if [[ -n "${{ inputs.allowed_tools }}" ]]; then
|
||||
TOOLS="$TOOLS,${{ inputs.allowed_tools }}"
|
||||
fi
|
||||
CMD_ARGS+=("--allowedTools" "$TOOLS")
|
||||
|
||||
# Add disallowed tools
|
||||
if [[ -n "${{ inputs.disallowed_tools }}" ]]; then
|
||||
CMD_ARGS+=("--disallowedTools" "${{ inputs.disallowed_tools }}")
|
||||
fi
|
||||
|
||||
echo "📝 Executing Claude Code with prompt from file..."
|
||||
|
||||
# Execute Claude Code with timeout, using stdin for the prompt
|
||||
TIMEOUT_SECONDS=$((${{ inputs.timeout_minutes }} * 60))
|
||||
timeout $TIMEOUT_SECONDS claude "${CMD_ARGS[@]}" < "${{ env.PROMPT_FILE }}"
|
||||
|
||||
echo "✅ Claude Code execution completed"
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ inputs.claude_code_oauth_token }}
|
||||
GITHUB_TOKEN: ${{ inputs.github_token }}
|
||||
@@ -0,0 +1,91 @@
|
||||
name: "Claude Issue Triage Action"
|
||||
description: "Automatically triage GitHub issues using Claude Code"
|
||||
|
||||
inputs:
|
||||
timeout_minutes:
|
||||
description: "Timeout in minutes for execution"
|
||||
required: false
|
||||
default: "5"
|
||||
claude_code_oauth_token:
|
||||
description: "Claude Code OAuth token for Pro subscription access"
|
||||
required: true
|
||||
github_token:
|
||||
description: "GitHub token with repo and issues permissions"
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Checkout repository code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Create prompt
|
||||
id: generate_prompt
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p /tmp/claude-prompts
|
||||
cat > /tmp/claude-prompts/claude-issue-triage-prompt.txt << 'EOF'
|
||||
You're an issue triage assistant for GitHub issues. Your task is to analyze the issue and select appropriate labels from the provided list.
|
||||
|
||||
IMPORTANT: Don't post any comments or messages to the issue. Your only action should be to apply labels.
|
||||
|
||||
Issue Information:
|
||||
- REPO: ${{ github.repository }}
|
||||
- ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
|
||||
TASK OVERVIEW:
|
||||
|
||||
1. First, fetch the list of labels available in this repository by running: `gh label list`. Run exactly this command with nothing else.
|
||||
|
||||
2. Next, use the GitHub tools to get context about the issue:
|
||||
- You have access to these tools:
|
||||
- mcp__github__get_issue: Use this to retrieve the current issue's details including title, description, and existing labels
|
||||
- mcp__github__get_issue_comments: Use this to read any discussion or additional context provided in the comments
|
||||
- mcp__github__update_issue: Use this to apply labels to the issue (do not use this for commenting)
|
||||
- mcp__github__search_issues: Use this to find similar issues that might provide context for proper categorization and to identify potential duplicate issues
|
||||
- mcp__github__list_issues: Use this to understand patterns in how other issues are labeled
|
||||
- Start by using mcp__github__get_issue to get the issue details
|
||||
|
||||
3. Analyze the issue content, considering:
|
||||
- The issue title and description
|
||||
- The type of issue (bug report, feature request, question, etc.)
|
||||
- Technical areas mentioned
|
||||
- Severity or priority indicators
|
||||
- User impact
|
||||
- Components affected
|
||||
|
||||
4. Select appropriate labels from the available labels list provided above:
|
||||
- Choose labels that accurately reflect the issue's nature
|
||||
- Be specific but comprehensive
|
||||
- Select priority labels if you can determine urgency (high-priority, med-priority, or low-priority)
|
||||
- Consider platform labels (android, ios) if applicable
|
||||
- If you find similar issues using mcp__github__search_issues, consider using a "duplicate" label if appropriate. Only do so if the issue is a duplicate of another OPEN issue.
|
||||
|
||||
5. Apply the selected labels:
|
||||
- Use mcp__github__update_issue to apply your selected labels
|
||||
- DO NOT post any comments explaining your decision
|
||||
- DO NOT communicate directly with users
|
||||
- If no labels are clearly applicable, do not apply any labels
|
||||
|
||||
IMPORTANT GUIDELINES:
|
||||
- Be thorough in your analysis
|
||||
- Only select labels from the provided list above
|
||||
- DO NOT post any comments to the issue
|
||||
- Your ONLY action should be to apply labels using mcp__github__update_issue
|
||||
- It's okay to not add any labels if none are clearly applicable
|
||||
EOF
|
||||
|
||||
echo "PROMPT<<DELIMITER" >> $GITHUB_OUTPUT
|
||||
cat /tmp/claude-prompts/claude-issue-triage-prompt.txt >> $GITHUB_OUTPUT
|
||||
echo "DELIMITER" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Run Claude Code
|
||||
uses: ./.github/actions/claude-code-action
|
||||
with:
|
||||
direct_prompt: ${{ steps.generate_prompt.outputs.PROMPT }}
|
||||
allowed_tools: "Bash(gh label list),mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues"
|
||||
timeout_minutes: ${{ inputs.timeout_minutes }}
|
||||
claude_code_oauth_token: ${{ inputs.claude_code_oauth_token }}
|
||||
github_token: ${{ inputs.github_token }}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"templateRepo": "MervinPraison/github-automation-template",
|
||||
"stack": "claude",
|
||||
"profile": "sdk",
|
||||
"mode": "hub",
|
||||
"hubRef": "v1.1.14",
|
||||
"templateVersion": "1.1.7",
|
||||
"installedAt": "2026-09-07T10:02:11Z"
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Idempotent CodeRabbit/Qodo kick for bot-opened PRs.
|
||||
* @see .github/workflows/auto-pr-comment.yml, claude.yml, bot-pr-recovery.yml
|
||||
*/
|
||||
|
||||
const KICK_AUTHORS = new Set(['MervinPraison', 'github-actions[bot]']);
|
||||
const BOT_PR_AUTHORS = new Set(['praisonai-triage-agent[bot]', 'github-actions[bot]']);
|
||||
|
||||
async function listAllComments(github, owner, repo, issueNumber) {
|
||||
if (typeof github.paginate === 'function') {
|
||||
return github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
}
|
||||
const { data } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
function kickAuthored(comment, marker) {
|
||||
return (
|
||||
KICK_AUTHORS.has(comment.user?.login) &&
|
||||
(comment.body || '').includes(marker)
|
||||
);
|
||||
}
|
||||
|
||||
function coderabbitKickPosted(comments) {
|
||||
return comments.some((c) => kickAuthored(c, '@coderabbitai review'));
|
||||
}
|
||||
|
||||
function qodoKickPosted(comments) {
|
||||
return comments.some((c) => kickAuthored(c, '/review'));
|
||||
}
|
||||
|
||||
function chainKickPosted(comments) {
|
||||
return coderabbitKickPosted(comments) && qodoKickPosted(comments);
|
||||
}
|
||||
|
||||
function isBotOpenedPr(pr) {
|
||||
if (BOT_PR_AUTHORS.has(pr.user?.login)) return true;
|
||||
return pr.user?.type === 'Bot';
|
||||
}
|
||||
|
||||
async function kickReviewChain(github, owner, repo, prNumber, core, preFetchedComments = null) {
|
||||
const comments = preFetchedComments || await listAllComments(github, owner, repo, prNumber);
|
||||
const needCoderabbit = !coderabbitKickPosted(comments);
|
||||
const needQodo = !qodoKickPosted(comments);
|
||||
|
||||
if (!needCoderabbit && !needQodo) {
|
||||
core?.info?.(`Review chain already kicked on PR #${prNumber}`);
|
||||
return { kicked: false, reason: 'already_kicked' };
|
||||
}
|
||||
|
||||
if (needCoderabbit) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: '@coderabbitai review',
|
||||
});
|
||||
}
|
||||
if (needQodo) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: '/review',
|
||||
});
|
||||
}
|
||||
core?.info?.(`Kicked review chain for PR #${prNumber} (coderabbit=${needCoderabbit}, qodo=${needQodo})`);
|
||||
return { kicked: true, coderabbit: needCoderabbit, qodo: needQodo };
|
||||
}
|
||||
|
||||
async function findOpenPrForIssue(github, owner, repo, issueNumber) {
|
||||
const prefix = `claude/issue-${issueNumber}-`;
|
||||
const { data: prs } = await github.rest.pulls.list({
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
sort: 'created',
|
||||
direction: 'desc',
|
||||
per_page: 30,
|
||||
});
|
||||
return (
|
||||
prs.find(
|
||||
(p) => (p.head?.ref || '').startsWith(prefix) && isBotOpenedPr(p)
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
async function kickReviewChainForIssue(github, owner, repo, issueNumber, core) {
|
||||
const pr = await findOpenPrForIssue(github, owner, repo, issueNumber);
|
||||
if (!pr) {
|
||||
core?.info?.(`No open PR for issue #${issueNumber}, skipping review kick`);
|
||||
return { kicked: false, reason: 'no_pr' };
|
||||
}
|
||||
const result = await kickReviewChain(github, owner, repo, pr.number, core);
|
||||
return { ...result, prNumber: pr.number };
|
||||
}
|
||||
|
||||
async function recoverStalledBotPrs(github, owner, repo, options, core) {
|
||||
const { prNumber = null, minAgeMs = 10 * 60 * 1000, maxRecover = 10 } = options || {};
|
||||
let prs;
|
||||
if (prNumber) {
|
||||
const { data } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
||||
prs = [data];
|
||||
} else if (typeof github.paginate === 'function') {
|
||||
prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
} else {
|
||||
const { data } = await github.rest.pulls.list({ owner, repo, state: 'open', per_page: 100 });
|
||||
prs = data;
|
||||
}
|
||||
|
||||
const cutoff = Date.now() - minAgeMs;
|
||||
let recovered = 0;
|
||||
for (const pr of prs) {
|
||||
if (recovered >= maxRecover) break;
|
||||
if (!isBotOpenedPr(pr)) continue;
|
||||
if (!prNumber && new Date(pr.created_at).getTime() > cutoff) continue;
|
||||
const comments = await listAllComments(github, owner, repo, pr.number);
|
||||
if (chainKickPosted(comments)) continue;
|
||||
await kickReviewChain(github, owner, repo, pr.number, core, comments);
|
||||
recovered += 1;
|
||||
}
|
||||
core?.info?.(`Recovery complete (${recovered} PR(s) kicked)`);
|
||||
return recovered;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
KICK_AUTHORS,
|
||||
BOT_PR_AUTHORS,
|
||||
listAllComments,
|
||||
chainKickPosted,
|
||||
coderabbitKickPosted,
|
||||
qodoKickPosted,
|
||||
isBotOpenedPr,
|
||||
kickReviewChain,
|
||||
findOpenPrForIssue,
|
||||
kickReviewChainForIssue,
|
||||
recoverStalledBotPrs,
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/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);
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 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]',
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Local self-test for merge-gate.js heuristics (no GitHub API).
|
||||
* Run: node .github/scripts/merge-gate-selftest.js
|
||||
*/
|
||||
const mg = require('./merge-gate.js');
|
||||
|
||||
let failed = 0;
|
||||
function assert(name, cond) {
|
||||
if (!cond) {
|
||||
console.error('FAIL:', name);
|
||||
failed++;
|
||||
} else {
|
||||
console.log('ok:', name);
|
||||
}
|
||||
}
|
||||
|
||||
// Stale FINAL: push after FINAL, no @claude since head
|
||||
const finals = [
|
||||
{ user: { login: 'github-actions[bot]' }, body: '@claude FINAL architecture reviewer', created_at: '2026-06-12T08:00:00Z' },
|
||||
];
|
||||
assert('stale when head after final', mg.isStaleFinalAfterPush(finals, '2026-06-12T09:00:00Z'));
|
||||
|
||||
const withRecovery = [
|
||||
...finals,
|
||||
{ user: { login: 'github-actions[bot]' }, body: '@claude FINAL architecture reviewer', created_at: '2026-06-12T09:30:00Z' },
|
||||
];
|
||||
assert('not stale when @claude after head', !mg.isStaleFinalAfterPush(withRecovery, '2026-06-12T09:00:00Z'));
|
||||
|
||||
const withClaudeReply = [
|
||||
...finals,
|
||||
{
|
||||
user: { login: 'praisonai-triage-agent[bot]' },
|
||||
body: "**Claude finished @MervinPraison's task** —— [View job](https://github.com/)",
|
||||
created_at: '2026-06-12T08:30:00Z',
|
||||
},
|
||||
];
|
||||
assert('not stale when Claude replied after FINAL', !mg.isStaleFinalAfterPush(withClaudeReply, '2026-06-12T09:00:00Z'));
|
||||
assert('claude final reply detected', mg.isClaudeFinalReplyComment(withClaudeReply[1]));
|
||||
|
||||
assert('cancelled detect-and-trigger does not block', mg.OPTIONAL_CANCELLED_CHECKS.has('detect-and-trigger'));
|
||||
|
||||
// Stale-FINAL recovery guards (PR #2560 push loop)
|
||||
const nowMs = Date.now();
|
||||
const iso = (ms) => new Date(ms).toISOString();
|
||||
const firstFinal = {
|
||||
user: { login: 'MervinPraison' },
|
||||
body: '@claude You are the FINAL architecture reviewer.',
|
||||
created_at: iso(nowMs - 20 * 60 * 1000),
|
||||
};
|
||||
const pushSoonAfter = mg.shouldSkipStaleFinalRecovery(
|
||||
[firstFinal],
|
||||
iso(nowMs - 16 * 60 * 1000)
|
||||
);
|
||||
assert('debounce push soon after FINAL', pushSoonAfter.skip && pushSoonAfter.reason.includes('soon after'));
|
||||
|
||||
const secondFinal = {
|
||||
user: { login: 'MervinPraison' },
|
||||
body: '@claude You are the FINAL architecture reviewer.',
|
||||
created_at: iso(nowMs - 20 * 60 * 1000),
|
||||
};
|
||||
const capped = mg.shouldSkipStaleFinalRecovery(
|
||||
[firstFinal, secondFinal],
|
||||
iso(nowMs - 2 * 60 * 1000)
|
||||
);
|
||||
assert('hourly cap blocks third FINAL in window', capped.skip && capped.reason.includes('capped'));
|
||||
|
||||
const botPush = mg.shouldSkipStaleFinalRecovery(
|
||||
[firstFinal],
|
||||
iso(nowMs - 16 * 60 * 1000),
|
||||
'praisonai-triage-agent[bot]'
|
||||
);
|
||||
assert('skip when automation pushed head', botPush.skip && botPush.reason.includes('automation'));
|
||||
|
||||
assert('claude automation login', mg.isClaudeAutomationLogin('praisonai-triage-agent[bot]'));
|
||||
|
||||
// Bot CHANGES_REQUESTED then APPROVE
|
||||
const reviews = [
|
||||
{ user: { login: 'coderabbit[bot]', type: 'Bot' }, state: 'CHANGES_REQUESTED', submitted_at: '2026-06-12T08:00:00Z' },
|
||||
{ user: { login: 'coderabbit[bot]', type: 'Bot' }, state: 'APPROVED', submitted_at: '2026-06-12T09:00:00Z' },
|
||||
];
|
||||
assert('bot approve clears CR', !mg.hasAnyChangesRequested(reviews));
|
||||
assert('human CR blocks', mg.hasAnyChangesRequested([
|
||||
{ user: { login: 'MervinPraison', type: 'User' }, state: 'CHANGES_REQUESTED', submitted_at: '2026-06-12T09:00:00Z' },
|
||||
]));
|
||||
|
||||
// Verdict after HEAD
|
||||
const verdictComments = [
|
||||
{ body: 'MERGE_GATE_VERDICT: APPROVE', created_at: '2026-06-12T08:00:00Z' },
|
||||
];
|
||||
assert('verdict before head rejected', mg.findMergeGateVerdict(verdictComments, null, '2026-06-12T09:00:00Z') === null);
|
||||
assert('verdict after head accepted', mg.findMergeGateVerdict(
|
||||
[{ body: 'MERGE_GATE_VERDICT: APPROVE', created_at: '2026-06-12T10:00:00Z' }],
|
||||
null,
|
||||
'2026-06-12T09:00:00Z'
|
||||
) === 'APPROVE');
|
||||
|
||||
const fallbackApprove = [
|
||||
{
|
||||
body: 'MERGE_GATE_VERDICT: APPROVE\n\nAutomated fallback — Claude assess did not post a verdict comment.',
|
||||
created_at: '2026-06-12T10:00:00Z',
|
||||
},
|
||||
];
|
||||
assert(
|
||||
'automated fallback APPROVE ignored when Opus required',
|
||||
mg.findMergeGateVerdict(fallbackApprove, null, '2026-06-12T09:00:00Z', { excludeAutomatedFallback: true }) === null
|
||||
);
|
||||
assert(
|
||||
'automated fallback APPROVE still visible without Opus-only filter',
|
||||
mg.findMergeGateVerdict(fallbackApprove, null, '2026-06-12T09:00:00Z') === 'APPROVE'
|
||||
);
|
||||
|
||||
const fallbackBlockNewFormat = [
|
||||
{
|
||||
body: 'MERGE_GATE_VERDICT: BLOCK\n\nAutomated fallback — Opus merge gate assessment did not complete.',
|
||||
created_at: '2026-06-12T10:00:00Z',
|
||||
},
|
||||
];
|
||||
assert(
|
||||
'new-format fallback BLOCK ignored when Opus required',
|
||||
mg.findMergeGateVerdict(fallbackBlockNewFormat, null, '2026-06-12T09:00:00Z', { excludeAutomatedFallback: true }) === null
|
||||
);
|
||||
assert(
|
||||
'new-format fallback BLOCK detected by marker helper',
|
||||
mg.isAutomatedFallbackVerdict(fallbackBlockNewFormat[0].body)
|
||||
);
|
||||
assert(
|
||||
'findMergeGateVerdict null-safe with explicit null options',
|
||||
mg.findMergeGateVerdict(fallbackApprove, null, '2026-06-12T09:00:00Z', null) === 'APPROVE'
|
||||
);
|
||||
|
||||
const noise = [{ user: { login: 'MervinPraison' }, body: '**Merge gate scan** — wait for `@claude`', created_at: new Date().toISOString() }];
|
||||
assert('diagnostic comment not a trigger', !mg.hasRecentClaudeTrigger(noise, 35));
|
||||
|
||||
// Cooldown skip requires an actual verdict on HEAD, not just a FINAL trigger comment
|
||||
const recentFinalOnHead = [
|
||||
{
|
||||
user: { login: 'MervinPraison' },
|
||||
body: '@claude You are the FINAL architecture reviewer.',
|
||||
created_at: '2026-06-27T10:00:00Z',
|
||||
},
|
||||
];
|
||||
assert(
|
||||
'final trigger alone does not count as verdict on head',
|
||||
mg.findMergeGateVerdict(recentFinalOnHead, null, '2026-06-27T09:55:00Z') === null
|
||||
);
|
||||
const recentVerdictOnHead = [
|
||||
...recentFinalOnHead,
|
||||
{
|
||||
user: { login: 'github-actions[bot]' },
|
||||
body: 'MERGE_GATE_VERDICT: APPROVE',
|
||||
created_at: '2026-06-27T10:05:00Z',
|
||||
},
|
||||
];
|
||||
assert(
|
||||
'verdict on head skips cooldown gate',
|
||||
mg.findMergeGateVerdict(recentVerdictOnHead, null, '2026-06-27T09:55:00Z') === 'APPROVE'
|
||||
);
|
||||
|
||||
// Sensitive + secrets
|
||||
assert('workflow path sensitive', mg.sensitivePathReasons([{ filename: '.github/workflows/foo.yml' }]).length === 1);
|
||||
assert('ci-only label exempts workflows', mg.sensitivePathReasons(
|
||||
[{ filename: '.github/workflows/foo.yml' }],
|
||||
[mg.WORKFLOW_ONLY_LABEL]
|
||||
).length === 0);
|
||||
assert('ci-only label not exempt mixed changes', mg.sensitivePathReasons(
|
||||
[{ filename: '.github/workflows/foo.yml' }, { filename: 'src/praisonaiui/foo.py' }],
|
||||
[mg.WORKFLOW_ONLY_LABEL]
|
||||
).length === 1);
|
||||
assert('secret in patch', mg.secretScanReasons([{ filename: 'x.py', patch: '+key = "ghp_abcdefghijklmnopqrstuvwxyz1234567890"' }]).length === 1);
|
||||
|
||||
// Claude run scoping — other PR branches must not block
|
||||
assert('other branch claude does not block', !mg.hasBlockingClaudeRunForPr(
|
||||
[{ event: 'issue_comment', head_branch: 'other-branch' }],
|
||||
'my-branch'
|
||||
));
|
||||
assert('same branch claude blocks', mg.hasBlockingClaudeRunForPr(
|
||||
[{ event: 'issue_comment', head_branch: 'my-branch' }],
|
||||
'my-branch'
|
||||
));
|
||||
assert('issues event never blocks', !mg.hasBlockingClaudeRunForPr(
|
||||
[{ event: 'issues', head_branch: 'my-branch' }],
|
||||
'my-branch'
|
||||
));
|
||||
|
||||
// Conflict rebase clears after bot completion + FINAL on HEAD (within 12h cooldown)
|
||||
const conflictNowMs = Date.now();
|
||||
const conflictIso = (offsetMs) => new Date(conflictNowMs + offsetMs).toISOString();
|
||||
const conflictTrigger = {
|
||||
user: { login: 'MervinPraison' },
|
||||
body: '@claude this PR has merge conflicts with main. Please rebase',
|
||||
created_at: conflictIso(-30 * 60 * 1000),
|
||||
};
|
||||
const rebaseDone = {
|
||||
user: { login: 'praisonai-triage-agent[bot]' },
|
||||
body: 'Rebase complete — PR #2308 onto latest main',
|
||||
created_at: conflictIso(-29 * 60 * 1000),
|
||||
};
|
||||
const finalAfterRebase = {
|
||||
user: { login: 'MervinPraison' },
|
||||
body: '@claude You are the FINAL architecture reviewer.',
|
||||
created_at: conflictIso(-20 * 60 * 1000),
|
||||
};
|
||||
const headAfterRebase = conflictIso(-21 * 60 * 1000);
|
||||
const rebaseComments = [conflictTrigger, rebaseDone, finalAfterRebase];
|
||||
assert('conflict blocks before rebase done', mg.hasRecentConflictComment([conflictTrigger], headAfterRebase));
|
||||
assert('conflict clears after rebase + FINAL on HEAD', !mg.hasRecentConflictComment(rebaseComments, headAfterRebase));
|
||||
assert('conflict 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);
|
||||
@@ -0,0 +1,940 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Run: node .github/scripts/pipeline-status-selftest.js
|
||||
*/
|
||||
const ps = require('./pipeline-status.js');
|
||||
const mg = require('./merge-gate.js');
|
||||
const config = require('./gate-config.js');
|
||||
|
||||
let failed = 0;
|
||||
function assert(name, cond) {
|
||||
if (!cond) {
|
||||
console.error('FAIL:', name);
|
||||
failed++;
|
||||
} else {
|
||||
console.log('ok:', name);
|
||||
}
|
||||
}
|
||||
|
||||
const kickComments = [
|
||||
{ user: { login: 'MervinPraison' }, body: '@coderabbitai review' },
|
||||
{ user: { login: 'MervinPraison' }, body: '/review' },
|
||||
];
|
||||
|
||||
const finalComment = {
|
||||
user: { login: 'MervinPraison' },
|
||||
body: '@claude You are the FINAL architecture reviewer.',
|
||||
created_at: '2026-06-26T10:00:00Z',
|
||||
};
|
||||
|
||||
assert(
|
||||
'reviews pending when not kicked',
|
||||
ps.deriveStage([], { ready: false, reasons: ['no FINAL Claude review trigger'] })
|
||||
=== 'pipeline/reviews-pending'
|
||||
);
|
||||
assert(
|
||||
'final pending after kick',
|
||||
ps.deriveStage(kickComments, { ready: false, reasons: ['no FINAL Claude review trigger'] })
|
||||
=== 'pipeline/final-claude-pending'
|
||||
);
|
||||
assert(
|
||||
'awaiting merge gate after bot review comment',
|
||||
ps.deriveStage(
|
||||
[
|
||||
{ user: { login: 'coderabbitai[bot]' }, body: 'review summary' },
|
||||
finalComment,
|
||||
],
|
||||
{ ready: false, reasons: ['recent @claude within 35min'] }
|
||||
) === 'pipeline/awaiting-merge-gate'
|
||||
);
|
||||
|
||||
assert('review bot logins configured', config.reviewBotLogins.includes('coderabbitai[bot]'));
|
||||
assert(
|
||||
'merge ready',
|
||||
ps.deriveStage([...kickComments, finalComment], { ready: true, reasons: [] })
|
||||
=== 'pipeline/merge-ready'
|
||||
);
|
||||
|
||||
const blockers = ps.deriveBlockerLabels({
|
||||
ready: false,
|
||||
reasons: ['CI not green on HEAD', 'SDK code added without test file changes — requires manual review'],
|
||||
});
|
||||
assert('maps ci blocker', blockers.includes('pipeline/blocked:ci'));
|
||||
assert('maps manual blocker', blockers.includes('pipeline/blocked:manual-review'));
|
||||
|
||||
assert(
|
||||
'internal link matches upstream base',
|
||||
mg.isInternalPullRequestLink(
|
||||
{ base: { repo: { full_name: 'MervinPraison/PraisonAI' } } },
|
||||
'MervinPraison',
|
||||
'PraisonAI'
|
||||
)
|
||||
);
|
||||
assert(
|
||||
'fork sync link rejected',
|
||||
!mg.isInternalPullRequestLink(
|
||||
{ number: 21, base: { repo: { full_name: 'Milkmange/PraisonAI' } } },
|
||||
'MervinPraison',
|
||||
'PraisonAI'
|
||||
)
|
||||
);
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Sync pipeline stage/blocker labels on open PRs.
|
||||
* @see .github/workflows/pipeline-status-sync.yml
|
||||
*/
|
||||
|
||||
const mergeGate = require('./merge-gate.js');
|
||||
const chain = require('./bot-pr-review-chain.js');
|
||||
const ciFix = require('./ci-failure-claude.js');
|
||||
const config = require('./gate-config.js');
|
||||
|
||||
const PIPELINE_PREFIX = 'pipeline/';
|
||||
const STAGE_LABELS = [
|
||||
'pipeline/reviews-pending',
|
||||
'pipeline/final-claude-pending',
|
||||
'pipeline/awaiting-merge-gate',
|
||||
'pipeline/merge-ready',
|
||||
'pipeline/merged',
|
||||
];
|
||||
const BLOCKER_LABELS = [
|
||||
'pipeline/blocked:ci',
|
||||
'pipeline/blocked:conflict',
|
||||
'pipeline/blocked:manual-review',
|
||||
'pipeline/blocked:cooldown',
|
||||
'pipeline/blocked:stale-final',
|
||||
'pipeline/blocked:no-final',
|
||||
];
|
||||
const ALL_PIPELINE_LABELS = [...STAGE_LABELS, ...BLOCKER_LABELS];
|
||||
|
||||
const LABEL_SPECS = [
|
||||
{ name: 'pipeline/reviews-pending', color: 'fbca04', description: 'Waiting for CodeRabbit/Qodo/Copilot reviews' },
|
||||
{ name: 'pipeline/final-claude-pending', color: 'd4c5f9', description: 'Reviews done; waiting for FINAL @claude' },
|
||||
{ name: 'pipeline/awaiting-merge-gate', color: 'c5def5', description: 'FINAL done; waiting for merge gate / CI' },
|
||||
{ name: 'pipeline/merge-ready', color: '0e8a16', description: 'Eligible for merge gate auto-merge' },
|
||||
{ name: 'pipeline/merged', color: '6f42c1', description: 'Merged via pipeline' },
|
||||
{ name: 'pipeline/blocked:ci', color: 'd93f0b', description: 'Blocked: CI not green on HEAD' },
|
||||
{ name: 'pipeline/blocked:conflict', color: 'b60205', description: 'Blocked: merge conflict or rebase pending' },
|
||||
{ name: 'pipeline/blocked:manual-review', color: 'e99695', description: 'Blocked: requires manual review' },
|
||||
{ name: 'pipeline/blocked:cooldown', color: 'fef2c0', description: 'Blocked: post-push or @claude cooldown' },
|
||||
{ name: 'pipeline/blocked:stale-final', color: 'f9d0c4', description: 'Blocked: FINAL stale after new commits' },
|
||||
{ name: 'pipeline/blocked:no-final', color: 'ededed', description: 'Blocked: no FINAL @claude trigger yet' },
|
||||
];
|
||||
|
||||
function reviewChainStarted(comments) {
|
||||
if (chain.chainKickPosted(comments)) return true;
|
||||
return comments.some((c) => config.reviewBotLogins.includes(c.user?.login));
|
||||
}
|
||||
|
||||
function deriveStage(comments, evalResult) {
|
||||
if (evalResult.ready) return 'pipeline/merge-ready';
|
||||
if (!reviewChainStarted(comments)) return 'pipeline/reviews-pending';
|
||||
if (!mergeGate.hasFinalClaudeReviewTrigger(comments)) return 'pipeline/final-claude-pending';
|
||||
return 'pipeline/awaiting-merge-gate';
|
||||
}
|
||||
|
||||
function reasonToBlockerLabel(reason) {
|
||||
const r = (reason || '').toLowerCase();
|
||||
if (r.includes('ci not green') || r.includes('sdk code changed but no ci')) return 'pipeline/blocked:ci';
|
||||
if (
|
||||
r.includes('conflict') ||
|
||||
r.includes('mergestate=dirty') ||
|
||||
r.includes('not mergeable')
|
||||
) return 'pipeline/blocked:conflict';
|
||||
if (
|
||||
r.includes('manual') ||
|
||||
r.includes('requires manual') ||
|
||||
r.includes('sensitive path') ||
|
||||
r.includes('no-auto-merge') ||
|
||||
r.includes('manual-only label') ||
|
||||
r.includes('without test') ||
|
||||
r.includes('product code') ||
|
||||
r.includes('files changed') ||
|
||||
r.includes('possible secret')
|
||||
) return 'pipeline/blocked:manual-review';
|
||||
if (r.includes('recent @claude') || r.includes('post-push buffer')) return 'pipeline/blocked:cooldown';
|
||||
if (r.includes('stale final')) return 'pipeline/blocked:stale-final';
|
||||
if (r.includes('no final claude')) return 'pipeline/blocked:no-final';
|
||||
if (r.includes('final claude not complete')) return 'pipeline/blocked:stale-final';
|
||||
if (r.includes('claude.yml in progress') || r.includes('claude-merge-gate-active')) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function deriveBlockerLabels(evalResult) {
|
||||
if (evalResult.ready) return [];
|
||||
const labels = new Set();
|
||||
for (const reason of evalResult.reasons || []) {
|
||||
const mapped = reasonToBlockerLabel(reason);
|
||||
if (mapped && mapped.startsWith('pipeline/blocked:')) labels.add(mapped);
|
||||
}
|
||||
return [...labels];
|
||||
}
|
||||
|
||||
function computePipelineLabels(comments, evalResult) {
|
||||
const stage = deriveStage(comments, evalResult);
|
||||
const blockers = stage === 'pipeline/merge-ready' ? [] : deriveBlockerLabels(evalResult);
|
||||
return { stage, blockers, all: [stage, ...blockers] };
|
||||
}
|
||||
|
||||
async function ensurePipelineLabels(github, owner, repo, core) {
|
||||
let existing;
|
||||
if (typeof github.paginate === 'function') {
|
||||
existing = await github.paginate(github.rest.issues.listLabelsForRepo, {
|
||||
owner,
|
||||
repo,
|
||||
per_page: 100,
|
||||
});
|
||||
} else {
|
||||
({ data: existing } = await github.rest.issues.listLabelsForRepo({
|
||||
owner,
|
||||
repo,
|
||||
per_page: 100,
|
||||
}));
|
||||
}
|
||||
const names = new Set(existing.map((l) => l.name));
|
||||
for (const spec of LABEL_SPECS) {
|
||||
if (names.has(spec.name)) continue;
|
||||
try {
|
||||
await github.rest.issues.createLabel({
|
||||
owner,
|
||||
repo,
|
||||
name: spec.name,
|
||||
color: spec.color,
|
||||
description: spec.description,
|
||||
});
|
||||
core?.info?.(`Created label ${spec.name}`);
|
||||
} catch (err) {
|
||||
if (err.status !== 422) throw err;
|
||||
core?.info?.(`Label ${spec.name} already exists`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function syncPipelineLabels(github, owner, repo, prNumber, core) {
|
||||
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
|
||||
if (ctx.pr.state !== 'open') return { synced: false, reason: 'not_open' };
|
||||
|
||||
const evalResult = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core
|
||||
);
|
||||
const { stage, blockers, all } = computePipelineLabels(ctx.comments, evalResult);
|
||||
|
||||
const current = ctx.labels.filter((l) => l.startsWith(PIPELINE_PREFIX));
|
||||
const desired = new Set(all);
|
||||
const toRemove = current.filter((l) => !desired.has(l) && l !== 'pipeline/merged');
|
||||
const toAdd = all.filter((l) => !current.includes(l));
|
||||
|
||||
for (const name of toRemove) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name });
|
||||
} catch (err) {
|
||||
if (err.status !== 404) throw err;
|
||||
}
|
||||
}
|
||||
if (toAdd.length) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels: toAdd,
|
||||
});
|
||||
}
|
||||
|
||||
core?.info?.(`PR #${prNumber}: stage=${stage} blockers=[${blockers.join(', ')}]`);
|
||||
|
||||
const ciNotGreen = (evalResult.reasons || []).some((r) =>
|
||||
r.toLowerCase().includes('ci not green')
|
||||
);
|
||||
if (ciNotGreen || blockers.includes('pipeline/blocked:ci')) {
|
||||
await ciFix.maybeTriggerCiFixClaude(github, owner, repo, prNumber, core);
|
||||
} else {
|
||||
await ciFix.maybeClearCiFixLabel(
|
||||
github, owner, repo, prNumber, ctx.labels, ctx.headSha, core
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
synced: true,
|
||||
stage,
|
||||
blockers,
|
||||
ready: evalResult.ready,
|
||||
reasons: evalResult.reasons,
|
||||
labels: ctx.labels,
|
||||
createdAt: new Date(ctx.pr.created_at).getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
async function dispatchMergeGateForOldestReady(github, owner, repo, readyCandidates, core) {
|
||||
if (!readyCandidates.length) return 0;
|
||||
readyCandidates.sort((a, b) => a.createdAt - b.createdAt);
|
||||
for (const cand of readyCandidates) {
|
||||
if ((cand.labels || []).includes('claude-merge-gate-active')) {
|
||||
core?.info?.(`Skip dispatch PR #${cand.prNumber}: merge gate already active`);
|
||||
continue;
|
||||
}
|
||||
await github.rest.repos.createDispatchEvent({
|
||||
owner,
|
||||
repo,
|
||||
event_type: 'claude-merge-gate',
|
||||
client_payload: { pr_number: cand.prNumber },
|
||||
});
|
||||
core?.info?.(`Dispatched merge gate for ready PR #${cand.prNumber}`);
|
||||
return cand.prNumber;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function syncOpenPullRequests(github, owner, repo, options, core) {
|
||||
const { maxPrs = 20, dispatchMergeGate = true } = options || {};
|
||||
await ensurePipelineLabels(github, owner, repo, core);
|
||||
let prs;
|
||||
if (maxPrs <= 100) {
|
||||
({ data: prs } = await github.rest.pulls.list({
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
}));
|
||||
} else {
|
||||
prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
}
|
||||
let synced = 0;
|
||||
const readyCandidates = [];
|
||||
for (const pr of prs) {
|
||||
if (synced >= maxPrs) break;
|
||||
if (pr.draft) continue;
|
||||
if (pr.head?.repo?.full_name && pr.head.repo.full_name !== `${owner}/${repo}`) continue;
|
||||
const result = await syncPipelineLabels(github, owner, repo, pr.number, core);
|
||||
if (result.ready) {
|
||||
readyCandidates.push({
|
||||
prNumber: pr.number,
|
||||
createdAt: result.createdAt || new Date(pr.created_at).getTime(),
|
||||
labels: result.labels || [],
|
||||
});
|
||||
}
|
||||
synced += 1;
|
||||
}
|
||||
let dispatched = 0;
|
||||
if (dispatchMergeGate && readyCandidates.length) {
|
||||
dispatched = await dispatchMergeGateForOldestReady(
|
||||
github, owner, repo, readyCandidates, core
|
||||
);
|
||||
}
|
||||
core?.info?.(`Pipeline label sync complete (${synced} PR(s), dispatched=${dispatched || 'none'})`);
|
||||
return synced;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
STAGE_LABELS,
|
||||
BLOCKER_LABELS,
|
||||
ALL_PIPELINE_LABELS,
|
||||
deriveStage,
|
||||
deriveBlockerLabels,
|
||||
computePipelineLabels,
|
||||
ensurePipelineLabels,
|
||||
syncPipelineLabels,
|
||||
syncOpenPullRequests,
|
||||
dispatchMergeGateForOldestReady,
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env node
|
||||
const chain = require('./pr-review-chain.js');
|
||||
|
||||
let failed = 0;
|
||||
function assert(name, cond) {
|
||||
if (!cond) {
|
||||
console.error('FAIL:', name);
|
||||
failed++;
|
||||
} else {
|
||||
console.log('ok:', name);
|
||||
}
|
||||
}
|
||||
|
||||
const coderabbit = {
|
||||
user: { login: 'coderabbitai[bot]' },
|
||||
body: '<!-- summarize by coderabbit.ai --> summary',
|
||||
created_at: '2026-07-02T07:16:00Z',
|
||||
};
|
||||
const greptile = {
|
||||
user: { login: 'greptile-apps[bot]' },
|
||||
body: '<h3>Greptile Summary</h3><p>Looks good</p>',
|
||||
created_at: '2026-07-02T07:19:00Z',
|
||||
};
|
||||
const copilotKick = {
|
||||
user: { login: 'MervinPraison' },
|
||||
body: '@copilot Do a thorough review',
|
||||
created_at: '2026-07-02T07:20:00Z',
|
||||
};
|
||||
const copilotReply = {
|
||||
user: { login: 'copilot-swe-agent' },
|
||||
body: 'Copilot review here',
|
||||
created_at: '2026-07-02T07:22:00Z',
|
||||
};
|
||||
|
||||
assert('prior not ready without greptile', !chain.priorReviewersReady([coderabbit]).ready);
|
||||
assert('prior ready with coderabbit + greptile', chain.priorReviewersReady([coderabbit, greptile]).ready);
|
||||
assert('copilot not ready before trigger', !chain.copilotReviewReady([coderabbit, greptile]).ready);
|
||||
assert(
|
||||
'copilot ready after reply',
|
||||
chain.copilotReviewReady([coderabbit, greptile, copilotKick, copilotReply]).ready
|
||||
);
|
||||
assert(
|
||||
'claude not ready before copilot',
|
||||
!chain.claudeFinalReady([coderabbit, greptile]).ready
|
||||
);
|
||||
assert(
|
||||
'claude ready after full chain',
|
||||
chain.claudeFinalReady([coderabbit, greptile, copilotKick, copilotReply]).ready
|
||||
);
|
||||
assert(
|
||||
'claude timeout fallback when copilot triggered but silent',
|
||||
chain.claudeFinalReady([coderabbit, greptile, copilotKick], [], { allowCopilotTimeout: true }).ready
|
||||
);
|
||||
assert(
|
||||
'no timeout fallback without copilot trigger',
|
||||
!chain.claudeFinalReady([coderabbit, greptile], [], { allowCopilotTimeout: true }).ready
|
||||
);
|
||||
assert(
|
||||
'skipCopilot proceeds after prior reviewers',
|
||||
chain.claudeFinalReady([coderabbit, greptile], [], { skipCopilot: true }).ready
|
||||
);
|
||||
|
||||
const oldPr = new Date(Date.now() - 31 * 60 * 1000).toISOString();
|
||||
assert(
|
||||
'prior not ready without coderabbit before timeout',
|
||||
!chain.priorReviewersReady([greptile], [], { prCreatedAt: new Date().toISOString(), requiredWaitMs: 30 * 60 * 1000 }).ready
|
||||
);
|
||||
assert(
|
||||
'prior timeout when coderabbit silent after 30m',
|
||||
chain.priorReviewersReady([greptile], [], { prCreatedAt: oldPr, requiredWaitMs: 30 * 60 * 1000 }).ready
|
||||
);
|
||||
assert(
|
||||
'claude FINAL timeout when required reviewer silent and no prior FINAL',
|
||||
chain.claudeFinalReady([greptile], [], { prCreatedAt: oldPr, requiredWaitMs: 30 * 60 * 1000 }).ready
|
||||
);
|
||||
assert(
|
||||
'claude FINAL timeout auto-skips copilot without explicit skipCopilot',
|
||||
chain.claudeFinalReady([greptile], [], { prCreatedAt: oldPr, requiredWaitMs: 30 * 60 * 1000 }).copilotSkipped
|
||||
);
|
||||
const finalClaude = {
|
||||
user: { login: 'github-actions[bot]' },
|
||||
body: '@claude You are the FINAL architecture reviewer for this PR.',
|
||||
created_at: '2026-07-02T07:25:00Z',
|
||||
};
|
||||
assert(
|
||||
'claude FINAL not re-posted when already triggered',
|
||||
!chain.claudeFinalReady([greptile, finalClaude], [], { prCreatedAt: oldPr, requiredWaitMs: 0 }).ready
|
||||
);
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Run: node .github/scripts/release-gate-selftest.js
|
||||
*/
|
||||
const rg = require('./release-gate.js');
|
||||
const config = require('./gate-config.js');
|
||||
|
||||
let failed = 0;
|
||||
function assert(name, cond) {
|
||||
if (!cond) {
|
||||
console.error('FAIL:', name);
|
||||
failed += 1;
|
||||
} else {
|
||||
console.log('ok:', name);
|
||||
}
|
||||
}
|
||||
|
||||
assert('package name is aiui', config.pypiPackageName === 'aiui');
|
||||
assert('bumpPatch works', rg.bumpPatch('0.3.127') === '0.3.128');
|
||||
|
||||
try {
|
||||
const versions = rg.readVersionsFromTree(process.cwd());
|
||||
assert('reads current version', /^\d+\.\d+\.\d+$/.test(versions.current));
|
||||
assert('computes target patch', versions.target === rg.bumpPatch(versions.current));
|
||||
} catch (err) {
|
||||
console.error('FAIL: readVersionsFromTree', err.message);
|
||||
failed += 1;
|
||||
}
|
||||
|
||||
assert('PACKAGE_PATHS includes pyproject', rg.PACKAGE_PATHS.includes('pyproject.toml'));
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Release gate preflight — path changes, CI SHA, dedupe, PyPI version checks (PraisonAIUI).
|
||||
*/
|
||||
|
||||
const https = require('https');
|
||||
const config = require('./gate-config.js');
|
||||
|
||||
const PACKAGE_PATHS = config.packagePaths;
|
||||
const ACTIVE_RELEASE_STATUSES = new Set([
|
||||
'queued', 'in_progress', 'waiting', 'pending', 'requested',
|
||||
]);
|
||||
|
||||
function bumpPatch(version) {
|
||||
const parts = version.split('.');
|
||||
if (parts.length !== 3) throw new Error(`Invalid version: ${version}`);
|
||||
return `${parts[0]}.${parts[1]}.${Number(parts[2]) + 1}`;
|
||||
}
|
||||
|
||||
function readVersionsFromTree(root = '.') {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const toml = fs.readFileSync(path.join(root, 'pyproject.toml'), 'utf8');
|
||||
const match = toml.match(/^version\s*=\s*"([^"]+)"/m);
|
||||
if (!match) throw new Error('Could not read aiui version from pyproject.toml');
|
||||
const current = match[1];
|
||||
const target = bumpPatch(current);
|
||||
return { current, target, packageName: config.pypiPackageName };
|
||||
}
|
||||
|
||||
function pypiVersionExists(packageName, version) {
|
||||
return new Promise((resolve) => {
|
||||
const req = https.get(
|
||||
`https://pypi.org/pypi/${packageName}/${version}/json`,
|
||||
{ timeout: 15000 },
|
||||
(res) => {
|
||||
res.resume();
|
||||
resolve(res.statusCode === 200);
|
||||
}
|
||||
);
|
||||
req.on('error', () => resolve(false));
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function hasActiveReleaseRun(github, owner, repo) {
|
||||
const runs = await github.rest.actions.listWorkflowRuns({
|
||||
owner,
|
||||
repo,
|
||||
workflow_id: 'pypi-release.yml',
|
||||
per_page: 20,
|
||||
});
|
||||
return runs.data.workflow_runs.some(
|
||||
(r) => ACTIVE_RELEASE_STATUSES.has(r.status) && !r.conclusion
|
||||
);
|
||||
}
|
||||
|
||||
function utcDayStart(now = new Date()) {
|
||||
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||
}
|
||||
|
||||
async function hasSuccessfulReleaseToday(github, owner, repo, now = new Date()) {
|
||||
const dayStart = utcDayStart(now);
|
||||
const runs = await github.rest.actions.listWorkflowRuns({
|
||||
owner,
|
||||
repo,
|
||||
workflow_id: 'pypi-release.yml',
|
||||
status: 'completed',
|
||||
per_page: 30,
|
||||
});
|
||||
return runs.data.workflow_runs.some(
|
||||
(r) => r.conclusion === 'success' && new Date(r.created_at) >= dayStart
|
||||
);
|
||||
}
|
||||
|
||||
async function lastGreenCiSha(github, owner, repo) {
|
||||
const runs = await github.rest.actions.listWorkflowRuns({
|
||||
owner,
|
||||
repo,
|
||||
workflow_id: config.ciWorkflowFile,
|
||||
branch: 'main',
|
||||
status: 'completed',
|
||||
per_page: 30,
|
||||
});
|
||||
const hit = runs.data.workflow_runs.find((r) => r.conclusion === 'success');
|
||||
return hit ? hit.head_sha : '';
|
||||
}
|
||||
|
||||
async function evaluateReleasePreflight(github, owner, repo, options, core) {
|
||||
const {
|
||||
headSha,
|
||||
isCiTrigger = false,
|
||||
bump = 'patch',
|
||||
} = options;
|
||||
|
||||
const reasons = [];
|
||||
const out = {
|
||||
ready: false,
|
||||
reasons,
|
||||
headSha: headSha || '',
|
||||
lastTag: '',
|
||||
targetVersion: '',
|
||||
packageName: config.pypiPackageName,
|
||||
};
|
||||
|
||||
if (bump !== 'patch') {
|
||||
reasons.push('only patch auto-release supported');
|
||||
return out;
|
||||
}
|
||||
|
||||
if (await hasActiveReleaseRun(github, owner, repo)) {
|
||||
reasons.push('PyPI Release already in progress or awaiting approval');
|
||||
return out;
|
||||
}
|
||||
|
||||
const referenceTime = options.now instanceof Date ? options.now : new Date();
|
||||
if (await hasSuccessfulReleaseToday(github, owner, repo, referenceTime)) {
|
||||
const day = referenceTime.toISOString().slice(0, 10);
|
||||
reasons.push(`already released today (UTC ${day}); max one patch release per day`);
|
||||
return out;
|
||||
}
|
||||
|
||||
let versions;
|
||||
try {
|
||||
versions = readVersionsFromTree();
|
||||
} catch (err) {
|
||||
reasons.push(err.message);
|
||||
return out;
|
||||
}
|
||||
out.targetVersion = versions.target;
|
||||
|
||||
const onPypi = await pypiVersionExists(config.pypiPackageName, versions.target);
|
||||
if (onPypi) {
|
||||
reasons.push(`already published: ${config.pypiPackageName}==${versions.target}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
let lastTag = '';
|
||||
try {
|
||||
lastTag = execSync('git describe --tags --match "v*" --abbrev=0', { encoding: 'utf8' }).trim();
|
||||
} catch {
|
||||
reasons.push('no v* tag found');
|
||||
return out;
|
||||
}
|
||||
out.lastTag = lastTag;
|
||||
|
||||
let changed = '';
|
||||
try {
|
||||
changed = execSync(
|
||||
`git diff --name-only ${lastTag} HEAD -- ${PACKAGE_PATHS.join(' ')}`,
|
||||
{ encoding: 'utf8' }
|
||||
).trim();
|
||||
} catch {
|
||||
reasons.push('git diff failed');
|
||||
return out;
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
reasons.push(`no changes in ${PACKAGE_PATHS.join(' or ')} since ${lastTag}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
if (isCiTrigger) {
|
||||
let mainSha = '';
|
||||
try {
|
||||
mainSha = execSync('git rev-parse origin/main', { encoding: 'utf8' }).trim();
|
||||
} catch {
|
||||
reasons.push('could not resolve origin/main');
|
||||
return out;
|
||||
}
|
||||
if (headSha !== mainSha) {
|
||||
reasons.push(`superseded: green SHA ${headSha.slice(0, 7)} != main ${mainSha.slice(0, 7)}`);
|
||||
return out;
|
||||
}
|
||||
out.headSha = headSha;
|
||||
} else {
|
||||
const evalSha = execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim();
|
||||
out.headSha = evalSha;
|
||||
const greenSha = await lastGreenCiSha(github, owner, repo);
|
||||
if (greenSha !== evalSha) {
|
||||
reasons.push(`CI not green on HEAD (last green: ${greenSha ? greenSha.slice(0, 7) : 'none'})`);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
out.ready = true;
|
||||
out.reasons = ['ready'];
|
||||
if (core) core.info(`Release preflight passed for ${out.headSha || headSha}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
bumpPatch,
|
||||
readVersionsFromTree,
|
||||
pypiVersionExists,
|
||||
hasSuccessfulReleaseToday,
|
||||
utcDayStart,
|
||||
evaluateReleasePreflight,
|
||||
lastGreenCiSha,
|
||||
ACTIVE_RELEASE_STATUSES,
|
||||
PACKAGE_PATHS,
|
||||
};
|
||||
@@ -1,8 +1,7 @@
|
||||
name: Auto Issue Comment
|
||||
|
||||
# Optional: automatically escalate every new issue to Claude for a full
|
||||
# investigation + draft fix. Disabled by default (only workflow_dispatch).
|
||||
# To enable auto-triage on every new issue, uncomment the `issues:` trigger.
|
||||
# Automatically escalate every new issue to Claude for a full investigation
|
||||
# + draft fix. Manual re-trigger: workflow_dispatch with issue_number.
|
||||
#
|
||||
# Note: `@claude` mentions only trigger claude.yml when the commenter is a
|
||||
# HUMAN user (or github-actions[bot] via PAT). This workflow posts the
|
||||
@@ -14,9 +13,9 @@ on:
|
||||
issue_number:
|
||||
description: "Issue number to triage with Claude"
|
||||
required: true
|
||||
type: number
|
||||
# issues:
|
||||
# types: [opened]
|
||||
type: string
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
# Escalate an issue to full Claude implementation by adding the `claude`
|
||||
@@ -25,10 +24,11 @@ jobs:
|
||||
trigger-claude:
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'issues' && !contains(toJSON(github.event.issue.labels), 'claude'))
|
||||
github.event_name == 'issues'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
actions: write
|
||||
steps:
|
||||
- name: Resolve issue number
|
||||
id: resolve
|
||||
@@ -52,18 +52,18 @@ jobs:
|
||||
});
|
||||
|
||||
const labels = (issue.labels || []).map(l => typeof l === 'string' ? l : l.name);
|
||||
if (labels.includes('claude')) {
|
||||
console.log(`Issue #${issueNumber} already has 'claude' label — skipping.`);
|
||||
return;
|
||||
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.`);
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
labels: ['claude'],
|
||||
});
|
||||
|
||||
const body = [
|
||||
'🤖 Auto-triaging with Claude.',
|
||||
'',
|
||||
@@ -84,3 +84,12 @@ jobs:
|
||||
issue_number: issueNumber,
|
||||
body,
|
||||
});
|
||||
|
||||
await github.rest.actions.createWorkflowDispatch({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: 'claude.yml',
|
||||
ref: 'main',
|
||||
inputs: { issue_number: String(issueNumber) },
|
||||
});
|
||||
console.log(`Dispatched Claude Assistant for issue #${issueNumber}`);
|
||||
@@ -1,525 +1,33 @@
|
||||
name: Auto PR Comment
|
||||
|
||||
# Review chain: CodeRabbit/Qodo/Gemini review first → Copilot → Claude (final)
|
||||
# For human PRs: CodeRabbit/Qodo auto-review → this workflow triggers Copilot → then Claude
|
||||
# For bot PRs: CodeRabbit/Qodo skip bots, so we trigger them explicitly first
|
||||
#
|
||||
# IMPORTANT: @copilot ignores bot comments. All @copilot mentions must use
|
||||
# PAT_TOKEN so the comment posts as MervinPraison (a real user).
|
||||
#
|
||||
# Claude trigger runs IN-BAND (same workflow) to avoid GitHub Actions
|
||||
# blocking bot-triggered workflows with action_required approval gates.
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
pull_request:
|
||||
types: [opened]
|
||||
types: [opened, synchronize]
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
schedule:
|
||||
- cron: '30 3,9,15,21 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
MAX_POLL_ATTEMPTS: 20
|
||||
POLL_DELAY_MS: 30000
|
||||
CLAUDE_TRIGGER_LOGINS: '["MervinPraison","github-actions[bot]"]'
|
||||
REVIEW_CHAIN_SKIP_COPILOT: '1'
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
# ================================================================
|
||||
# HUMAN PRs: Trigger @copilot AFTER CodeRabbit posts its summary
|
||||
# ================================================================
|
||||
copilot-after-coderabbit:
|
||||
if: |
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
github.event.comment.user.login == 'coderabbitai[bot]' &&
|
||||
contains(github.event.comment.body, 'summarize by coderabbit')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
outputs:
|
||||
pr_number: ${{ steps.trigger.outputs.pr_number }}
|
||||
triggered: ${{ steps.trigger.outputs.triggered }}
|
||||
steps:
|
||||
- name: Post Copilot review request (as user via PAT)
|
||||
id: trigger
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
// Check for duplicates — posted by MervinPraison (PAT) or github-actions[bot] (legacy)
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@copilot') &&
|
||||
(c.user.login === 'MervinPraison' || c.user.login === 'github-actions[bot]')
|
||||
);
|
||||
if (alreadyPosted) {
|
||||
console.log('Copilot already triggered, skipping');
|
||||
core.setOutput('triggered', 'false');
|
||||
core.setOutput('pr_number', context.issue.number.toString());
|
||||
return;
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@copilot Do a thorough review of this PR. Read ALL existing reviewer comments above from Qodo, Coderabbit, and Gemini first — incorporate their findings.\n\nReview areas:\n1. **Bloat check**: Are changes minimal and focused? Any unnecessary code or scope creep?\n2. **Security**: Any hardcoded secrets, unsafe eval/exec, missing input validation?\n3. **Performance**: Any module-level heavy imports? Hot-path regressions?\n4. **Tests**: Are tests included? Do they cover the changes adequately?\n5. **Backward compat**: Any public API changes without deprecation?\n6. **Code quality**: DRY violations, naming conventions, error handling?\n7. **Address reviewer feedback**: If Qodo, Coderabbit, or Gemini flagged valid issues, include them in your review\n8. Suggest specific improvements with code examples where possible'
|
||||
});
|
||||
core.setOutput('triggered', 'true');
|
||||
core.setOutput('pr_number', context.issue.number.toString());
|
||||
|
||||
# Fallback: Trigger @copilot after Qodo review (if Coderabbit doesn't fire)
|
||||
copilot-after-qodo:
|
||||
if: |
|
||||
github.event_name == 'pull_request_review' &&
|
||||
github.event.review.user.login == 'qodo-code-review[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
outputs:
|
||||
pr_number: ${{ steps.trigger.outputs.pr_number }}
|
||||
triggered: ${{ steps.trigger.outputs.triggered }}
|
||||
steps:
|
||||
- name: Post Copilot review request (as user via PAT)
|
||||
id: trigger
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@copilot') &&
|
||||
(c.user.login === 'MervinPraison' || c.user.login === 'github-actions[bot]')
|
||||
);
|
||||
if (alreadyPosted) {
|
||||
console.log('Copilot already triggered, skipping');
|
||||
core.setOutput('triggered', 'false');
|
||||
core.setOutput('pr_number', context.issue.number.toString());
|
||||
return;
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@copilot Do a thorough review of this PR. Read ALL existing reviewer comments above first.\n\nReview areas:\n1. **Bloat check**: Are changes minimal and focused?\n2. **Security**: Any hardcoded secrets, unsafe eval/exec, missing input validation?\n3. **Performance**: Any module-level heavy imports? Hot-path regressions?\n4. **Tests**: Are tests included? Do they cover the changes adequately?\n5. **Backward compat**: Any public API changes without deprecation?\n6. **Code quality**: DRY violations, naming conventions, error handling?\n7. Suggest specific improvements with code examples where possible'
|
||||
});
|
||||
core.setOutput('triggered', 'true');
|
||||
core.setOutput('pr_number', context.issue.number.toString());
|
||||
|
||||
# ================================================================
|
||||
# FALLBACK: Claude trigger for cases where Copilot chain fails/skips
|
||||
# Ensures Claude always runs after sufficient review time
|
||||
# ================================================================
|
||||
claude-fallback-timeout:
|
||||
if: |
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.action == 'opened'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
review-chain:
|
||||
# Grant union of callee job permissions (repos with default_workflow_permissions: read
|
||||
# reject reusable calls when the caller ceiling is lower than any callee job).
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Wait for review window (15 min)
|
||||
run: sleep 900
|
||||
|
||||
- name: Check if Claude already triggered
|
||||
id: check_claude
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
core.setOutput('comments_count', comments.data.length.toString());
|
||||
|
||||
// Collect all bot reviewer comments for context
|
||||
const botReviews = comments.data.filter(c =>
|
||||
['coderabbitai[bot]', 'qodo-code-review[bot]', 'gemini-code-assist[bot]',
|
||||
'copilot-swe-agent', 'Copilot', 'greptile-apps[bot]'].some(bot =>
|
||||
c.user.login.toLowerCase().includes(bot.toLowerCase())
|
||||
)
|
||||
);
|
||||
core.setOutput('review_count', botReviews.length.toString());
|
||||
|
||||
- name: Aggregate reviews and trigger Claude
|
||||
if: steps.check_claude.outputs.already_triggered != 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const comments = await github.rest.issues.listComments({issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, per_page: 100});
|
||||
const reviews = {coderabbit: [], qodo: [], gemini: [], copilot: [], greptile: [], others: []};
|
||||
comments.data.forEach(c => {
|
||||
const login = c.user.login.toLowerCase();
|
||||
const body = c.body.substring(0, 500);
|
||||
if (login.includes('coderabbit')) reviews.coderabbit.push(body);
|
||||
else if (login.includes('qodo')) reviews.qodo.push(body);
|
||||
else if (login.includes('gemini')) reviews.gemini.push(body);
|
||||
else if (login.includes('copilot')) reviews.copilot.push(body);
|
||||
else if (login.includes('greptile')) reviews.greptile.push(body);
|
||||
else if (c.user.type === 'Bot') reviews.others.push(body);
|
||||
});
|
||||
const summaryParts = [];
|
||||
if (reviews.coderabbit.length) summaryParts.push('CodeRabbit: ' + reviews.coderabbit.length + ' comments');
|
||||
if (reviews.qodo.length) summaryParts.push('Qodo: ' + reviews.qodo.length + ' comments');
|
||||
if (reviews.gemini.length) summaryParts.push('Gemini: ' + reviews.gemini.length + ' comments');
|
||||
if (reviews.copilot.length) summaryParts.push('Copilot: ' + reviews.copilot.length + ' comments');
|
||||
if (reviews.greptile.length) summaryParts.push('Greptile: ' + reviews.greptile.length + ' comments');
|
||||
const reviewSummary = summaryParts.length ? summaryParts.join(' | ') : 'No bot reviews detected';
|
||||
const claudeBody = '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAIUI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on PraisonAIUI (src/praisonaiui, tests, docs). Do not expand scope into praisonaiagents or the monorepo unless the issue explicitly requires it. ' + reviewSummary + '.\n\n' +
|
||||
'Review Context: ' + comments.data.length + ' total comments, ' +
|
||||
(reviews.coderabbit.length + reviews.qodo.length + reviews.gemini.length + reviews.copilot.length + reviews.greptile.length) + ' bot reviews.\n\n' +
|
||||
'Phase 1: Review per AGENTS.md principles (protocol-driven, backward compatible, no perf impact).\n' +
|
||||
'Phase 2: FIX valid issues found by prior reviewers.\n' +
|
||||
'Phase 3: Provide final verdict (approve or request changes).';
|
||||
await github.rest.issues.createComment({issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: claudeBody});
|
||||
console.log('Claude fallback triggered on PR #' + context.issue.number + ' with ' + reviewSummary);
|
||||
|
||||
# ================================================================
|
||||
# Claude FINAL review — polls for Copilot's response, then triggers
|
||||
# This runs IN-BAND under the PAT context (MervinPraison), avoiding
|
||||
# the action_required approval gate that blocks bot-triggered workflows.
|
||||
# ================================================================
|
||||
claude-after-copilot:
|
||||
needs: [copilot-after-coderabbit]
|
||||
if: |
|
||||
always() &&
|
||||
needs.copilot-after-coderabbit.result == 'success' &&
|
||||
needs.copilot-after-coderabbit.outputs.triggered == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Wait for Copilot to respond (poll)
|
||||
id: poll
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
|
||||
MAX_POLL_ATTEMPTS: ${{ env.MAX_POLL_ATTEMPTS }}
|
||||
POLL_DELAY_MS: ${{ env.POLL_DELAY_MS }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
// Poll for Copilot's response — check every 30s for up to 10 minutes
|
||||
const maxAttempts = parseInt(process.env.MAX_POLL_ATTEMPTS, 10);
|
||||
const delayMs = parseInt(process.env.POLL_DELAY_MS, 10);
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
console.log(`Poll attempt ${i + 1}/${maxAttempts}...`);
|
||||
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr,
|
||||
owner,
|
||||
repo,
|
||||
per_page: 50,
|
||||
sort: 'created',
|
||||
direction: 'desc'
|
||||
});
|
||||
|
||||
// Look for Copilot's response (copilot-swe-agent or Copilot)
|
||||
const copilotResponse = comments.data.find(c =>
|
||||
(c.user.login === 'copilot-swe-agent' ||
|
||||
c.user.login === 'Copilot' ||
|
||||
c.user.login.toLowerCase().includes('copilot')) &&
|
||||
c.user.type === 'Bot'
|
||||
);
|
||||
|
||||
if (copilotResponse) {
|
||||
console.log(`Copilot responded at ${copilotResponse.created_at}`);
|
||||
core.setOutput('copilot_responded', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
// Also check PR reviews for Copilot
|
||||
const reviews = await github.rest.pulls.listReviews({
|
||||
pull_number: pr,
|
||||
owner,
|
||||
repo
|
||||
});
|
||||
|
||||
const copilotReview = reviews.data.find(r =>
|
||||
r.user.login.toLowerCase().includes('copilot')
|
||||
);
|
||||
|
||||
if (copilotReview) {
|
||||
console.log(`Copilot review submitted at ${copilotReview.submitted_at}`);
|
||||
core.setOutput('copilot_responded', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
if (i < maxAttempts - 1) {
|
||||
console.log(`Copilot hasn't responded yet, waiting ${delayMs/1000}s...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Copilot did not respond within timeout, triggering Claude anyway');
|
||||
core.setOutput('copilot_responded', 'timeout');
|
||||
|
||||
- name: Check if Claude already triggered
|
||||
id: check_claude
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
|
||||
- name: Trigger Claude final review
|
||||
if: steps.check_claude.outputs.already_triggered != 'true'
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAIUI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on PraisonAIUI (src/praisonaiui, tests, docs). Do not expand scope into praisonaiagents or the monorepo unless the issue explicitly requires it. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items'
|
||||
});
|
||||
console.log(`Claude triggered on PR #${pr}`);
|
||||
|
||||
# ================================================================
|
||||
# Claude after Qodo fallback path (same polling approach)
|
||||
# ================================================================
|
||||
claude-after-copilot-qodo:
|
||||
needs: [copilot-after-qodo]
|
||||
if: |
|
||||
always() &&
|
||||
needs.copilot-after-qodo.result == 'success' &&
|
||||
needs.copilot-after-qodo.outputs.triggered == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Wait for Copilot to respond (poll)
|
||||
id: poll
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
|
||||
MAX_POLL_ATTEMPTS: ${{ env.MAX_POLL_ATTEMPTS }}
|
||||
POLL_DELAY_MS: ${{ env.POLL_DELAY_MS }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const maxAttempts = parseInt(process.env.MAX_POLL_ATTEMPTS, 10);
|
||||
const delayMs = parseInt(process.env.POLL_DELAY_MS, 10);
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
console.log(`Poll attempt ${i + 1}/${maxAttempts}...`);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr, owner, repo,
|
||||
per_page: 50, sort: 'created', direction: 'desc'
|
||||
});
|
||||
const copilotResponse = comments.data.find(c =>
|
||||
(c.user.login === 'copilot-swe-agent' ||
|
||||
c.user.login === 'Copilot' ||
|
||||
c.user.login.toLowerCase().includes('copilot')) &&
|
||||
c.user.type === 'Bot'
|
||||
);
|
||||
if (copilotResponse) {
|
||||
console.log(`Copilot responded at ${copilotResponse.created_at}`);
|
||||
core.setOutput('copilot_responded', 'true');
|
||||
return;
|
||||
}
|
||||
const reviews = await github.rest.pulls.listReviews({
|
||||
pull_number: pr, owner, repo
|
||||
});
|
||||
const copilotReview = reviews.data.find(r =>
|
||||
r.user.login.toLowerCase().includes('copilot')
|
||||
);
|
||||
if (copilotReview) {
|
||||
console.log(`Copilot review submitted at ${copilotReview.submitted_at}`);
|
||||
core.setOutput('copilot_responded', 'true');
|
||||
return;
|
||||
}
|
||||
if (i < maxAttempts - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
console.log('Copilot did not respond within timeout, triggering Claude anyway');
|
||||
core.setOutput('copilot_responded', 'timeout');
|
||||
|
||||
- name: Check if Claude already triggered
|
||||
id: check_claude
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
|
||||
- name: Trigger Claude final review
|
||||
if: steps.check_claude.outputs.already_triggered != 'true'
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAIUI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on PraisonAIUI (src/praisonaiui, tests, docs). Do not expand scope into praisonaiagents or the monorepo unless the issue explicitly requires it. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items'
|
||||
});
|
||||
console.log(`Claude triggered on PR #${pr}`);
|
||||
|
||||
# ================================================================
|
||||
# Claude after Gemini — restores the trigger lost from the deleted
|
||||
# chain-claude-after-copilot.yml workflow.
|
||||
# Fires when Gemini posts a review comment on a PR or issue.
|
||||
# ================================================================
|
||||
claude-after-gemini:
|
||||
if: |
|
||||
github.event_name == 'issue_comment' &&
|
||||
(
|
||||
github.event.comment.user.login == 'gemini-code-assist[bot]' ||
|
||||
contains(github.event.comment.body, 'Review completed by Gemini CLI') ||
|
||||
contains(github.event.comment.body, 'The fix is ready for review. Please test')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check if Claude already triggered
|
||||
id: check_claude
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
|
||||
- name: Post Claude analysis request (Lead Engineer)
|
||||
if: steps.check_claude.outputs.already_triggered != 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the Lead Engineer. If the branch is under MervinPraison/PraisonAIUI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on PraisonAIUI (src/praisonaiui, tests, docs). Do not expand scope into praisonaiagents or the monorepo unless the issue explicitly requires it. Read ALL analysis and reviews above carefully (Gemini, CodeRabbit, Qodo, Copilot, etc).\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items'
|
||||
});
|
||||
|
||||
# ================================================================
|
||||
# BOT PRs: CodeRabbit/Qodo skip bot-authored PRs by default.
|
||||
# Trigger them explicitly. Do NOT trigger Copilot here — it will
|
||||
# be triggered by copilot-after-coderabbit/qodo when they finish.
|
||||
# ================================================================
|
||||
bot-pr-trigger-reviews:
|
||||
if: |
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.action == 'opened' &&
|
||||
(
|
||||
github.event.pull_request.user.login == 'github-actions[bot]' ||
|
||||
github.event.pull_request.user.login == 'praisonai-triage-agent[bot]' ||
|
||||
github.event.pull_request.user.type == 'Bot'
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Trigger CodeRabbit and Qodo reviews
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = context.payload.pull_request.number;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
// 1. Trigger CodeRabbit review
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr, owner, repo,
|
||||
body: '@coderabbitai review'
|
||||
});
|
||||
console.log(`Triggered CodeRabbit for bot PR #${pr}`);
|
||||
|
||||
// 2. Trigger Qodo review
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr, owner, repo,
|
||||
body: '/review'
|
||||
});
|
||||
console.log(`Triggered Qodo for bot PR #${pr}`);
|
||||
|
||||
// 3. Trigger Gemini review (commented out)
|
||||
// await github.rest.issues.createComment({
|
||||
// issue_number: pr, owner, repo,
|
||||
// body: '@gemini review this PR'
|
||||
// });
|
||||
// console.log(`Triggered Gemini for bot PR #${pr}`);
|
||||
|
||||
// NOTE: @copilot is NOT triggered here.
|
||||
// It will be triggered by copilot-after-coderabbit or copilot-after-qodo
|
||||
// once those bots finish, ensuring Copilot always reviews LAST.
|
||||
console.log('Copilot will be triggered after CodeRabbit/Qodo complete.');
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.14
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Bot PR Recovery
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/15 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'Specific PR number to kick (optional)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
recover:
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.14
|
||||
pr_number: ${{ github.event.inputs.pr_number || '' }}
|
||||
@@ -0,0 +1,32 @@
|
||||
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.14
|
||||
pr_number: ${{ github.event.inputs.pr_number || '' }}
|
||||
@@ -5,9 +5,11 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
python:
|
||||
name: python
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -28,10 +30,36 @@ jobs:
|
||||
run: ruff check src/praisonaiui
|
||||
|
||||
- name: Test with pytest
|
||||
# Integration tests are environment-sensitive (LLM API keys,
|
||||
# long-running flows). Unit tests are the contract CI guards.
|
||||
# Unit tests are the primary CI contract; integration smoke validates backends.
|
||||
run: pytest tests/unit -v --no-cov --timeout=60
|
||||
|
||||
- name: Integration smoke tests
|
||||
run: pytest tests/integration/test_agentic_roundtrip.py tests/test_feature_sdk_backends.py -q --no-cov --timeout=120
|
||||
|
||||
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
|
||||
|
||||
docs:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
@@ -62,3 +90,4 @@ jobs:
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./site
|
||||
force_orphan: true
|
||||
@@ -0,0 +1,95 @@
|
||||
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.14
|
||||
- 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
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.14
|
||||
pr_number: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }}
|
||||
merge_method: ${{ github.event.inputs.merge_method || 'merge' }}
|
||||
+10
-277
@@ -1,291 +1,24 @@
|
||||
name: Claude Assistant
|
||||
|
||||
# Triggers:
|
||||
# - issues:opened → lightweight triage (welcome comment + label)
|
||||
# - issues:labeled → full implementation when `claude` label applied
|
||||
# - issues:assigned → full implementation when issue assigned to Claude
|
||||
# - issue_comment → full implementation when @claude mentioned (issues + PRs)
|
||||
# - pull_request_review → full implementation when @claude mentioned in review
|
||||
# - pull_request_review_comment → full implementation when @claude mentioned inline
|
||||
#
|
||||
# Focus: CLAUDE only. Copilot and CodeRabbit are GitHub Apps that react to PRs
|
||||
# automatically — no workflow file needed. auto-pr-comment.yml orchestrates the
|
||||
# CodeRabbit → Copilot → Claude review chain for PRs.
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, labeled, assigned]
|
||||
types: [assigned, labeled, opened]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
# ================================================================
|
||||
# Job 1 — Issue triage (runs on ANY new issue, including from external users)
|
||||
# Posts a welcome comment. Does NOT execute Claude Code.
|
||||
# To escalate to full implementation, a maintainer adds the `claude` label
|
||||
# or mentions @claude in a comment — which triggers Job 2.
|
||||
# ================================================================
|
||||
issue-triage:
|
||||
if: |
|
||||
github.event_name == 'issues' &&
|
||||
github.event.action == 'opened' &&
|
||||
!contains(toJSON(github.event.issue.labels), 'claude')
|
||||
runs-on: ubuntu-latest
|
||||
claude:
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Post welcome comment
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const body = [
|
||||
'👋 Thanks for opening an issue in **PraisonAIUI**.',
|
||||
'',
|
||||
'A maintainer will triage this shortly. If you would like Claude to investigate and draft a fix, a maintainer can:',
|
||||
'',
|
||||
'- Apply the `claude` label to this issue, **or**',
|
||||
'- Mention `@claude` in a comment with any additional instructions.',
|
||||
'',
|
||||
'Please make sure your issue includes:',
|
||||
'',
|
||||
'1. A clear description of the behaviour you expect vs what you see.',
|
||||
'2. Minimal reproduction steps (code snippet or `aiui.template.yaml`).',
|
||||
'3. Version: `python -c "import praisonaiui; print(praisonaiui.__version__)"`.',
|
||||
'4. Any relevant log output.',
|
||||
'',
|
||||
'See [AGENTS.md](https://github.com/MervinPraison/PraisonAIUI/blob/main/AGENTS.md) for project conventions.'
|
||||
].join('\n');
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body
|
||||
});
|
||||
|
||||
# ================================================================
|
||||
# Job 2 — Full Claude Code implementation
|
||||
# Runs when:
|
||||
# - A comment containing `@claude` is posted on an issue or PR
|
||||
# - The `claude` label is added to an issue
|
||||
# - The issue is assigned to a Claude bot
|
||||
# - A PR review or review-comment contains `@claude`
|
||||
# ================================================================
|
||||
claude-response:
|
||||
env:
|
||||
ISSUE_NUMBER_RESOLVED: ${{ github.event.issue.number || github.event.pull_request.number }}
|
||||
if: |
|
||||
github.actor != 'dependabot[bot]' &&
|
||||
github.actor != 'cursor[bot]' &&
|
||||
github.actor != 'renovate[bot]' &&
|
||||
(
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && github.event.action == 'labeled' && contains(toJSON(github.event.label), 'claude')) ||
|
||||
(github.event_name == 'issues' && github.event.action == 'assigned')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
ref: ${{ github.event.issue.pull_request && format('refs/pull/{0}/head', github.event.issue.number) || github.event.repository.default_branch }}
|
||||
|
||||
- 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"
|
||||
label_trigger: "claude"
|
||||
allowed_bots: "praisonai-triage-agent[bot]"
|
||||
timeout_minutes: "30"
|
||||
direct_prompt: |
|
||||
You are implementing changes in **MervinPraison/PraisonAIUI** (YAML-driven server-driven UI with agentic chat).
|
||||
Follow `AGENTS.md` at the repository root. If a conflict arises between this prompt and `AGENTS.md`, `AGENTS.md` wins.
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
STEP 0 — GIT & GITHUB CLI
|
||||
═══════════════════════════════════════════════════════════════
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
gh auth setup-git
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
STEP 1 — READ GUIDELINES
|
||||
═══════════════════════════════════════════════════════════════
|
||||
Read `AGENTS.md` and any `docs/principles.md`. Key invariants:
|
||||
• Cold import of `praisonaiui` must stay under 200 ms; module count must not grow.
|
||||
• Heavy / optional deps (`mcp`, `langchain`, `llama_index`, `semantic_kernel`,
|
||||
`slack_sdk`, `discord.py`, `botbuilder.core`, `openai`, `anthropic`, `mistralai`,
|
||||
`google.generativeai`) must be **lazy-imported** — never eager.
|
||||
• Public API is verb-first. New classes in `src/praisonaiui/message.py` ending
|
||||
in `Message` with a verb prefix (Ask*, Do*, Request*, …) are forbidden —
|
||||
use a module-level `async def` verb helper instead (see `prompt`, `error`).
|
||||
• Deprecations must emit a runtime `warnings.warn(..., DeprecationWarning, stacklevel=2)`
|
||||
on first access, not only in the docstring.
|
||||
• Deterministic serialisation in anything reaching the wire (sorted keys, stable field
|
||||
order). See existing `to_dict` patterns.
|
||||
• Multi-agent + async safe: no global mutable state without a lock.
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
STEP 2 — ARCHITECTURE VALIDATION & ROUTING (MANDATORY before any code)
|
||||
═══════════════════════════════════════════════════════════════
|
||||
Answer these questions inline in your response, then proceed only if you pass:
|
||||
|
||||
ROUTING:
|
||||
1. **Core aiui** (`src/praisonaiui/`) — protocols, message types, server, features.
|
||||
2. **Features** (`src/praisonaiui/features/`) — opt-in capabilities (MCP, platforms, lifecycle, audio, auth, sharing, etc.).
|
||||
3. **UI builders** (`src/praisonaiui/ui.py`) — declarative component helpers.
|
||||
4. **Frontend** (`src/frontend/`) — React components / stores / SSE handlers.
|
||||
5. **Tests** (`tests/unit/`, `tests/integration/`) — new features require both smoke and real-agentic tests.
|
||||
6. **Docs** (`docs/`) — canonical name per feature, migration table for renames.
|
||||
|
||||
EXTERNAL REPOS (do NOT edit from within this repo — route the work to the right place and stop):
|
||||
• Core SDK: `MervinPraison/PraisonAI` (`praisonaiagents`, `praisonai`) — agent runtime.
|
||||
• Tools: `MervinPraison/PraisonAI-Tools` — tool implementations.
|
||||
• Docs site: `MervinPraison/PraisonAIDocs` — product docs.
|
||||
If the requested change belongs in an external repo, comment on the issue explaining
|
||||
where it should live and DO NOT open a PR in this repo.
|
||||
|
||||
CONCEPTUAL CHECKS (if ANY fails — add a comment, do NOT open a PR):
|
||||
• Does it duplicate an existing public symbol in `src/praisonaiui/__init__.py`?
|
||||
• Does it add new class-first message types (`class Ask*Message`, `class *Message`
|
||||
with a verb in the name)? → Use a verb-first helper instead.
|
||||
• Does it add a new hard dependency to `pyproject.toml` `[project] dependencies`?
|
||||
→ Move to `[project.optional-dependencies]` and wrap imports lazily.
|
||||
• Does it break backward compat (rename, signature change, removal)? → Add
|
||||
a deprecation alias that emits `DeprecationWarning`; never hard-remove before the
|
||||
documented removal version (e.g. `0.5.0`).
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
STEP 3 — IMPLEMENT
|
||||
═══════════════════════════════════════════════════════════════
|
||||
Single branch for the whole flow (reuse the same name in STEP 5):
|
||||
BRANCH="claude/issue-${{ env.ISSUE_NUMBER_RESOLVED }}-$(date +%Y%m%d-%H%M%S)"
|
||||
git checkout -b "$BRANCH"
|
||||
|
||||
Make minimal, focused changes:
|
||||
• Stay within the files listed in the issue's "Scope" / "Files to change" section.
|
||||
• Prefer extending existing modules over adding new ones.
|
||||
• No emojis, no comments, no markdown except where the user asked for it.
|
||||
• Python ≥ 3.10 style (`list[...]` not `List[...]`, `X | None` not `Optional[X]`).
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
STEP 4 — TEST / VERIFY
|
||||
═══════════════════════════════════════════════════════════════
|
||||
Run these targeted checks (NOT the full suite):
|
||||
|
||||
# 1. Lint on only the files you changed:
|
||||
CHANGED=$(git diff --name-only origin/main...HEAD | grep -E '\.py$' | tr '\n' ' ')
|
||||
[ -n "$CHANGED" ] && ruff check $CHANGED
|
||||
|
||||
# 2. Unit tests for the module(s) you touched:
|
||||
pytest tests/unit/<your_new_test_file>.py -v --tb=short
|
||||
|
||||
# 3. Import-time invariant (MUST stay under 200 ms cold):
|
||||
python -c "import time, sys; t=time.time(); import praisonaiui; dt=(time.time()-t)*1000; heavy=[m for m in sys.modules if any(h in m for h in ['langchain','llama_index','mcp','slack','discord','botbuilder','openai.','anthropic.','mistralai','google.generativeai'])]; print(f'{dt:.1f}ms modules={len(sys.modules)} leaked={heavy}'); assert heavy == [], f'Heavy deps leaked: {heavy}'"
|
||||
|
||||
# 4. Real agentic end-to-end test (write this into tests/integration/
|
||||
# if your feature changes the agent-facing API):
|
||||
# from praisonaiagents import Agent
|
||||
# import praisonaiui as aiui
|
||||
# agent = Agent(name="demo", instructions="You are a helpful assistant")
|
||||
# result = agent.start("Say hello in one sentence")
|
||||
# print(result)
|
||||
|
||||
If anything fails, fix the root cause (never skip, xfail, or delete the test).
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
STEP 5 — COMMIT, PUSH, OPEN PR (MANDATORY if you produced commits)
|
||||
═══════════════════════════════════════════════════════════════
|
||||
git add -A
|
||||
git commit -m "feat: <short summary> (fixes #${{ env.ISSUE_NUMBER_RESOLVED }})"
|
||||
git push -u origin "$BRANCH"
|
||||
REPO="${{ github.repository }}"
|
||||
COUNT=$(gh pr list --repo "$REPO" --head "$BRANCH" --json number --jq 'length')
|
||||
if [ "$COUNT" -gt 0 ]; then
|
||||
gh pr list --repo "$REPO" --head "$BRANCH" --json url --jq '.[0].url'
|
||||
else
|
||||
gh pr create --repo "$REPO" --base main --head "$BRANCH" \
|
||||
--title "feat: <short title>" \
|
||||
--body "Fixes #${{ env.ISSUE_NUMBER_RESOLVED }}
|
||||
|
||||
## Summary
|
||||
|
||||
<what changed in one paragraph>
|
||||
|
||||
## Before / After
|
||||
|
||||
<copy-pasteable snippet per public API change>
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
<copy from the issue; tick boxes only with commit SHA + file path evidence>
|
||||
|
||||
## Test evidence
|
||||
|
||||
\`\`\`
|
||||
<paste pytest output>
|
||||
\`\`\`
|
||||
|
||||
## Import-time proof
|
||||
|
||||
\`\`\`
|
||||
<paste the python -c import-time output>
|
||||
\`\`\`
|
||||
"
|
||||
fi
|
||||
|
||||
CRITICAL: Do NOT stop after `git push`. You MUST complete `gh pr create` (or print the
|
||||
existing PR URL from the step above) and post that URL on the issue. Never ask a
|
||||
human to open the PR manually.
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
STEP 6 — RESPOND TO REVIEW FEEDBACK (if this run was triggered by a review comment)
|
||||
═══════════════════════════════════════════════════════════════
|
||||
If you were invoked by a PR review comment, you are the Lead Engineer for this PR:
|
||||
• Read ALL reviewer comments in chronological order (CodeRabbit → Copilot → human).
|
||||
• Fix every VALID issue by pushing commits to THIS branch (do NOT open a new PR).
|
||||
• Reply with a summary of which comments you addressed, which you disagreed with (and why).
|
||||
• Never ship a fix that removes/weakens a test.
|
||||
|
||||
allowed_tools: |
|
||||
Bash(git:*)
|
||||
Bash(python:*)
|
||||
Bash(pip:*)
|
||||
Bash(pytest:*)
|
||||
Bash(ruff:*)
|
||||
Bash(gh:*)
|
||||
Bash(npm:*)
|
||||
Bash(npx:*)
|
||||
Bash(python -m pytest:*)
|
||||
Bash(python -m pip:*)
|
||||
Bash(python -m ruff:*)
|
||||
View
|
||||
GlobTool
|
||||
GrepTool
|
||||
BatchTool
|
||||
Edit
|
||||
Replace
|
||||
mcp__github__get_issue
|
||||
mcp__github__get_issue_comments
|
||||
mcp__github__update_issue
|
||||
actions: read
|
||||
id-token: write
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.14
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Deploy Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: deploy-docs
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install AIUI
|
||||
run: pip install -e .
|
||||
|
||||
- name: Build docs with AIUI
|
||||
run: |
|
||||
aiui build --output site --minify
|
||||
cp CNAME site/CNAME
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./site
|
||||
force_orphan: true
|
||||
@@ -0,0 +1,52 @@
|
||||
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.14
|
||||
- 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'] });
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
name: Nightly Release Gate
|
||||
|
||||
# Preflight before PyPI Release (patch bump).
|
||||
# Primary: CI success on main (workflow_run).
|
||||
# Backstop: nightly cron at 00:00 UTC.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Run preflight only; do not dispatch pypi-release.yml'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
workflow_run:
|
||||
workflows:
|
||||
- CI
|
||||
types:
|
||||
- completed
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
|
||||
concurrency:
|
||||
group: nightly-release-gate
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
github.event_name == 'schedule' ||
|
||||
(
|
||||
github.event_name == 'workflow_run' &&
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.head_branch == 'main' &&
|
||||
github.event.workflow_run.event == 'push'
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
steps:
|
||||
- name: Resolve trigger context
|
||||
id: ctx
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_run" ]; then
|
||||
echo "checkout_sha=${{ github.event.workflow_run.head_sha }}" >> "$GITHUB_OUTPUT"
|
||||
echo "source=ci" >> "$GITHUB_OUTPUT"
|
||||
echo "is_ci=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "checkout_sha=main" >> "$GITHUB_OUTPUT"
|
||||
echo "source=nightly" >> "$GITHUB_OUTPUT"
|
||||
echo "is_ci=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
ref: ${{ steps.ctx.outputs.checkout_sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fetch main and tags
|
||||
run: git fetch --tags origin main
|
||||
|
||||
- name: Preflight — path changes, CI SHA, dedupe
|
||||
id: preflight
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
HEAD_SHA: ${{ steps.ctx.outputs.checkout_sha }}
|
||||
IS_CI: ${{ steps.ctx.outputs.is_ci }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const rg = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/release-gate.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const headSha = process.env.HEAD_SHA;
|
||||
const isCi = process.env.IS_CI === 'true';
|
||||
|
||||
const result = await rg.evaluateReleasePreflight(
|
||||
github, owner, repo,
|
||||
{ headSha, isCiTrigger: isCi, bump: 'patch' },
|
||||
core
|
||||
);
|
||||
|
||||
core.setOutput('should_release', result.ready ? 'true' : 'false');
|
||||
core.setOutput('reason', result.reasons.join('; '));
|
||||
core.setOutput('head_sha', result.headSha || headSha);
|
||||
core.setOutput('last_tag', result.lastTag || '');
|
||||
core.setOutput('target_version', result.targetVersion || '');
|
||||
|
||||
core.summary.addRaw('## Release gate preflight\n');
|
||||
core.summary.addRaw(`Ready: ${result.ready}\n`);
|
||||
core.summary.addRaw(`Reason: ${result.reasons.join('; ')}\n`);
|
||||
if (result.targetVersion) {
|
||||
core.summary.addRaw(`Target: aiui==${result.targetVersion}\n`);
|
||||
}
|
||||
await core.summary.write();
|
||||
|
||||
if (!result.ready) {
|
||||
core.info(`Skip release: ${result.reasons.join('; ')}`);
|
||||
}
|
||||
|
||||
- name: Dry run summary
|
||||
if: inputs.dry_run == true
|
||||
run: |
|
||||
echo "Dry run — preflight only, no pypi-release dispatch."
|
||||
echo "should_release=${{ steps.preflight.outputs.should_release }}"
|
||||
echo "reason=${{ steps.preflight.outputs.reason }}"
|
||||
|
||||
- name: Dispatch PyPI Release (patch)
|
||||
if: steps.preflight.outputs.should_release == 'true' && inputs.dry_run != true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh workflow run pypi-release.yml \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--ref main \
|
||||
-f bump=patch \
|
||||
-f source=${{ steps.ctx.outputs.source }} \
|
||||
-f trigger_sha=${{ steps.preflight.outputs.head_sha }} \
|
||||
-f dry_run=false
|
||||
echo "Dispatched pypi-release.yml (patch, source=${{ steps.ctx.outputs.source }})"
|
||||
echo "Uses pypi-auto environment for ci/nightly patch releases."
|
||||
@@ -0,0 +1,19 @@
|
||||
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.14
|
||||
@@ -0,0 +1,232 @@
|
||||
name: PyPI Release
|
||||
|
||||
# Requires GitHub Environment "pypi" (manual) or "pypi-auto" (ci/nightly patch).
|
||||
# Secrets: PYPI_TOKEN (or PYPI_API_TOKEN), GH_TOKEN
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
description: 'Version bump type for aiui'
|
||||
required: true
|
||||
default: patch
|
||||
type: choice
|
||||
options:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
dry_run:
|
||||
description: 'Preview versions and steps without publishing or pushing'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
version:
|
||||
description: 'Override version (empty = auto bump from pyproject.toml)'
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
source:
|
||||
description: 'Trigger source (audit trail)'
|
||||
required: false
|
||||
default: manual
|
||||
type: string
|
||||
trigger_sha:
|
||||
description: 'SHA that triggered this release'
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: pypi-release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
environment: >-
|
||||
${{
|
||||
(inputs.source == 'ci' || inputs.source == 'nightly') &&
|
||||
inputs.bump == 'patch' &&
|
||||
'pypi-auto' || 'pypi'
|
||||
}}
|
||||
env:
|
||||
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN || secrets.PYPI_API_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
PACKAGE: aiui
|
||||
steps:
|
||||
- name: Release audit
|
||||
run: |
|
||||
echo "Source: ${{ inputs.source }}"
|
||||
echo "Trigger SHA: ${{ inputs.trigger_sha }}"
|
||||
echo "Bump: ${{ inputs.bump }}"
|
||||
echo "Environment: ${{ (inputs.source == 'ci' || inputs.source == 'nightly') && inputs.bump == 'patch' && 'pypi-auto' || 'pypi' }}"
|
||||
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
|
||||
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config user.name "MervinPraison"
|
||||
git config user.email "[email protected]"
|
||||
gh auth setup-git
|
||||
|
||||
- name: Compute release version
|
||||
id: versions
|
||||
env:
|
||||
BUMP: ${{ inputs.bump }}
|
||||
VERSION_OVERRIDE: ${{ inputs.version }}
|
||||
run: |
|
||||
python <<'PY'
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SEMVER = re.compile(r"^\d+\.\d+\.\d+$")
|
||||
bump = os.environ["BUMP"]
|
||||
override = os.environ.get("VERSION_OVERRIDE", "").strip()
|
||||
|
||||
def bump_version(version: str) -> str:
|
||||
parts = version.split(".")
|
||||
if len(parts) != 3:
|
||||
print(f"Invalid version: {version}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
maj, min_, patch = (int(parts[0]), int(parts[1]), int(parts[2]))
|
||||
if bump == "major":
|
||||
return f"{maj + 1}.0.0"
|
||||
if bump == "minor":
|
||||
return f"{maj}.{min_ + 1}.0"
|
||||
return f"{maj}.{min_}.{patch + 1}"
|
||||
|
||||
pyproject = Path("pyproject.toml")
|
||||
content = pyproject.read_text()
|
||||
match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE)
|
||||
if not match:
|
||||
print("Could not read version from pyproject.toml", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
current = match.group(1)
|
||||
if not SEMVER.match(current):
|
||||
print(f"Invalid current version: {current}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if override:
|
||||
if not SEMVER.match(override):
|
||||
print(f"Invalid version override: {override}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
new_version = override
|
||||
else:
|
||||
new_version = bump_version(current)
|
||||
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh:
|
||||
fh.write(f"current_version={current}\n")
|
||||
fh.write(f"new_version={new_version}\n")
|
||||
print(f"aiui: {current} -> {new_version}")
|
||||
PY
|
||||
|
||||
- name: Dry run summary
|
||||
if: inputs.dry_run
|
||||
run: |
|
||||
echo "Dry run — no publish, commit, or push."
|
||||
echo "Package: ${{ env.PACKAGE }}"
|
||||
echo "Version: ${{ steps.versions.outputs.current_version }} -> ${{ steps.versions.outputs.new_version }}"
|
||||
echo "Would: bump pyproject.toml + __version__.py, uv publish, commit, tag, GitHub release"
|
||||
|
||||
- name: Bump version files
|
||||
if: inputs.dry_run != true
|
||||
env:
|
||||
CURRENT: ${{ steps.versions.outputs.current_version }}
|
||||
NEW_VERSION: ${{ steps.versions.outputs.new_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python <<'PY'
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
current = os.environ["CURRENT"]
|
||||
new = os.environ["NEW_VERSION"]
|
||||
|
||||
pyproject = Path("pyproject.toml")
|
||||
content = pyproject.read_text()
|
||||
pyproject.write_text(content.replace(f'version = "{current}"', f'version = "{new}"', 1))
|
||||
|
||||
version_py = Path("src/praisonaiui/__version__.py")
|
||||
vcontent = version_py.read_text()
|
||||
version_py.write_text(re.sub(
|
||||
r'__version__ = "[^"]+"',
|
||||
f'__version__ = "{new}"',
|
||||
vcontent,
|
||||
count=1,
|
||||
))
|
||||
PY
|
||||
|
||||
- name: Publish aiui to PyPI
|
||||
if: inputs.dry_run != true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf dist
|
||||
uv lock
|
||||
uv build
|
||||
uv publish
|
||||
|
||||
- name: Wait for aiui on PyPI
|
||||
if: inputs.dry_run != true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ steps.versions.outputs.new_version }}"
|
||||
MAX_WAIT=600
|
||||
INTERVAL=30
|
||||
START=$(date +%s)
|
||||
|
||||
while true; do
|
||||
if curl -fsSL "https://pypi.org/pypi/${PACKAGE}/${VERSION}/json" >/dev/null; then
|
||||
echo "✅ ${PACKAGE}==${VERSION} is available on PyPI"
|
||||
exit 0
|
||||
fi
|
||||
ELAPSED=$(( $(date +%s) - START ))
|
||||
if [ "$ELAPSED" -ge "$MAX_WAIT" ]; then
|
||||
echo "❌ Timeout waiting for ${PACKAGE}==${VERSION} on PyPI"
|
||||
exit 1
|
||||
fi
|
||||
echo "⏳ Waiting for ${PACKAGE}==${VERSION} ($(( MAX_WAIT - ELAPSED ))s remaining...)"
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
|
||||
- name: Commit, tag, and release
|
||||
if: inputs.dry_run != true
|
||||
env:
|
||||
VERSION: ${{ steps.versions.outputs.new_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="v${VERSION}"
|
||||
git add pyproject.toml uv.lock src/praisonaiui/__version__.py
|
||||
git diff --cached --quiet && { echo "No version files to commit"; exit 0; }
|
||||
git commit -m "Release ${TAG}"
|
||||
git tag -f "${TAG}"
|
||||
git pull --rebase origin main
|
||||
git tag -f "${TAG}"
|
||||
git push origin main
|
||||
git push --tags -f
|
||||
gh release create "${TAG}" \
|
||||
--title "PraisonAIUI ${TAG}" \
|
||||
--notes "Release ${TAG}" \
|
||||
--latest
|
||||
|
||||
- name: Verify release
|
||||
if: inputs.dry_run != true
|
||||
run: echo "✅ ${{ env.PACKAGE }}==${{ steps.versions.outputs.new_version }}"
|
||||
@@ -0,0 +1,33 @@
|
||||
name: Sync Claude Secrets from PraisonAI
|
||||
|
||||
# GitHub does not allow reading secret values back via API.
|
||||
# This workflow triggers the sync job in MervinPraison/PraisonAI, which copies:
|
||||
# CLAUDE_APP_ID, CLAUDE_APP_PRIVATE_KEY, CLAUDE_CODE_OAUTH_TOKEN, GH_TOKEN
|
||||
#
|
||||
# Requires SECRETS_ADMIN_PAT in PraisonAI (repo Settings → Secrets).
|
||||
# If sync fails, copy secrets manually:
|
||||
# PraisonAI → Settings → Secrets → Actions
|
||||
# PraisonAIUI → Settings → Secrets → Actions (same names)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
trigger-sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger PraisonAI secret sync
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh workflow run sync-secrets-to-aiui.yml \
|
||||
--repo MervinPraison/PraisonAI \
|
||||
--ref main \
|
||||
-f target_repo=MervinPraison/PraisonAIUI
|
||||
echo "Triggered: https://github.com/MervinPraison/PraisonAI/actions/workflows/sync-secrets-to-aiui.yml"
|
||||
echo ""
|
||||
echo "If sync fails (401/403), refresh SECRETS_ADMIN_PAT in PraisonAI,"
|
||||
echo "or copy CLAUDE_APP_ID + CLAUDE_APP_PRIVATE_KEY + GH_TOKEN manually."
|
||||
+3
-1
@@ -44,6 +44,7 @@ Thumbs.db
|
||||
# Project specific
|
||||
aiui/
|
||||
*.log
|
||||
*.err
|
||||
.praison/
|
||||
.gemini/
|
||||
.internal/
|
||||
@@ -55,4 +56,5 @@ aiui-output/
|
||||
_site
|
||||
|
||||
.praisonai
|
||||
tmp/
|
||||
tmp/
|
||||
*.db
|
||||
@@ -22,6 +22,7 @@ Deep Analysis → Gap Analysis → Critical Review → Plan → Create Issue →
|
||||
- **No competitor names in public surfaces**: Never mention competitor product, company, or project names in GitHub issues, PRs, commit messages, release notes, docs, or any other public-facing content. Refer to capabilities generically (e.g., "self-improving agent pattern", "external agent framework").
|
||||
- **Test before merge**: never merge unvalidated PRs. Always clone the branch locally and run the tests.
|
||||
- **Minimal code change**: prefer upstream one-line fixes over downstream workarounds.
|
||||
- **Autonomous polling — NEVER ask the user to "check now"**: after posting feedback to `@claude`, submitting a trigger comment, or any state that requires an external agent to push commits, the assistant itself waits ~10 minutes (`sleep 600`) and then re-checks. Do not stop and wait for the human to type "check now". Only surface control back to the human when a round is fully complete (PR merged or 3-round feedback cap hit) or a blocking decision is required (e.g. rebase strategy, conflicting feature intent).
|
||||
|
||||
---
|
||||
|
||||
@@ -536,6 +537,11 @@ sleep 600
|
||||
# → return to Phase 12 (fetch new commits, re-run checks)
|
||||
```
|
||||
|
||||
**The assistant runs this `sleep 600` itself — it does NOT pause the loop to ask the user "check now".** After the 10-minute wait completes, the assistant immediately re-fetches the PR, re-runs the test suite, and either merges, posts the next feedback round, or reports a final status. The human is only prompted when:
|
||||
- The PR merges successfully (report + move to next issue).
|
||||
- The 3-round feedback cap is hit (escalate).
|
||||
- A design decision is needed that the assistant cannot make unilaterally (e.g. "should `allow` policy persist or not?").
|
||||
|
||||
Repeat Phases 12 → 13 → 14 until the mergeability gate in Phase 13 is fully green, or escalate to a human after 3 failed rounds.
|
||||
|
||||
### When the PR has merge conflicts
|
||||
@@ -668,3 +674,4 @@ gh issue view $ISSUE_NUM --repo $REPO --json state
|
||||
6. **Always verify** the issue auto-closed after merge.
|
||||
7. **All `run_command` calls use `timeout`** to avoid hangs (user global rule).
|
||||
8. **No proactive `.md` creation** beyond this workflow file (user global rule).
|
||||
9. **Never ask the user "check now"**: between rounds the assistant runs `sleep 600` itself and keeps driving. Only return control to the human on merge, 3-round cap, or an irresolvable design decision.
|
||||
@@ -138,7 +138,23 @@ Final rule: conclude only when evidence shows missing = 0.
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
PUBLISH WORKFLOW (reference)
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Step 1 — Publish praisonaiagents (Core SDK):
|
||||
GitHub Actions (recommended):
|
||||
GitHub → Actions → "PyPI Release" → Run workflow → bump: patch
|
||||
Requires repo secrets: PYPI_TOKEN, GH_TOKEN
|
||||
Requires GitHub Environment "pypi" with required reviewers (Settings → Environments)
|
||||
|
||||
Manual — Publish aiui (PraisonAIUI):
|
||||
cd /Users/praison/PraisonAIUI
|
||||
# Bump version in pyproject.toml and src/praisonaiui/__version__.py
|
||||
rm -rf dist && uv lock && uv build && uv publish --token "$PYPI_TOKEN"
|
||||
git add pyproject.toml uv.lock src/praisonaiui/__version__.py
|
||||
git commit -m "Release vX.Y.Z" && git tag vX.Y.Z && git push && git push --tags
|
||||
gh release create vX.Y.Z --title "PraisonAIUI vX.Y.Z" --notes "Release vX.Y.Z" --latest
|
||||
|
||||
PyPI package name: aiui
|
||||
Verify: pip index versions aiui | head -1
|
||||
|
||||
PraisonAI stack (separate repo — publish SDK before wrapper):
|
||||
cd /Users/praison/praisonai-package/src/praisonai-agents
|
||||
praisonai publish pypi
|
||||
# Uses uv internally: uv lock → uv build → uv publish
|
||||
|
||||
@@ -111,9 +111,12 @@ YAML config → Pydantic models → Compiler → JSON manifests → React SPA
|
||||
### Frontend Build
|
||||
1. Source in `src/frontend/src/` (React 19 + TypeScript + Vite)
|
||||
2. Build: `cd src/frontend && npm run build`
|
||||
3. Output: `src/frontend/dist/`
|
||||
4. Sync to templates: copy `dist/{index.html,assets/,icon.svg}` → `src/praisonaiui/templates/frontend/`
|
||||
5. Compiler copies templates to output dir during `aiui build`
|
||||
3. Optional A2UI renderer (full agent-driven UI catalog): `npm install @a2ui/react @a2ui/web_core`
|
||||
4. Output: `src/frontend/dist/`
|
||||
5. Sync to templates: copy `dist/{index.html,assets/,icon.svg}` → `src/praisonaiui/templates/frontend/`
|
||||
6. Compiler copies templates to output dir during `aiui build`
|
||||
|
||||
Chat inline media (agent-generated images): `RunEventType.MESSAGE_ELEMENT` — see `docs/features/image-preview-chat.md`
|
||||
|
||||
## Gotchas
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# Bootstrap Architecture
|
||||
|
||||
PraisonAIUI provides a unified bootstrap entrypoint through `aiui run app.py` that automatically detects and handles different application patterns.
|
||||
|
||||
## Supported Patterns
|
||||
|
||||
### 1. Chat Applications
|
||||
```python
|
||||
import praisonaiui as aiui
|
||||
|
||||
@aiui.reply
|
||||
async def on_message(message: str):
|
||||
await aiui.say(f"You said: {message}")
|
||||
```
|
||||
|
||||
**Run**: `aiui run app.py`
|
||||
|
||||
### 2. Dashboard Applications
|
||||
```python
|
||||
from praisonaiui.server import create_app
|
||||
import uvicorn
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = create_app()
|
||||
uvicorn.run(app, host="0.0.0.0", port=8082)
|
||||
```
|
||||
|
||||
**Run**: `aiui run app.py` (automatically detects `create_app()` pattern)
|
||||
|
||||
### 3. Gateway Applications
|
||||
```python
|
||||
import asyncio
|
||||
from praisonaiui.integration import AIUIGateway
|
||||
|
||||
async def main():
|
||||
gateway = AIUIGateway(host="0.0.0.0", port=8083)
|
||||
# ... register agents
|
||||
await gateway.start()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
**Run**: `aiui run app.py` (auto-detects gateway pattern and switches to `praisonai` backend)
|
||||
|
||||
## Bootstrap Detection
|
||||
|
||||
The CLI automatically detects application types:
|
||||
|
||||
- **Gateway Pattern**: Async `main()` function + AIUIGateway imports → Uses `praisonai` backend
|
||||
- **Dashboard Pattern**: `create_app()` usage → Uses `standalone` backend
|
||||
- **Chat Pattern**: `@reply` decorators → Uses `standalone` backend
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### From Manual Bootstrap
|
||||
**Before:**
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
|
||||
**After:**
|
||||
```bash
|
||||
aiui run app.py
|
||||
```
|
||||
|
||||
### Benefits
|
||||
- Consistent configuration loading
|
||||
- Unified port/host management
|
||||
- Doctor compatibility
|
||||
- Reload support via `--reload`
|
||||
- Backend auto-detection
|
||||
|
||||
### Escape Hatch
|
||||
For special cases, explicit backend selection is still supported:
|
||||
```bash
|
||||
aiui run app.py --backend praisonai # Force gateway backend
|
||||
aiui run app.py --backend standalone # Force standalone backend
|
||||
```
|
||||
@@ -366,3 +366,7 @@ 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
@@ -0,0 +1,37 @@
|
||||
# Security Policy
|
||||
|
||||
We take the security of PraisonAIUI seriously and appreciate responsible
|
||||
disclosure of vulnerabilities.
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Security fixes are applied to the latest released version of PraisonAIUI.
|
||||
Please make sure you are on the most recent release before reporting.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Please do not open a public issue for security vulnerabilities.**
|
||||
|
||||
Report privately through GitHub's **Private Vulnerability Reporting**:
|
||||
|
||||
1. Go to the [Security tab](https://github.com/MervinPraison/PraisonAIUI/security)
|
||||
of this repository.
|
||||
2. Click **"Report a vulnerability"** to open a private advisory.
|
||||
3. Include as much detail as you can:
|
||||
- Affected component(s) and version.
|
||||
- Steps to reproduce.
|
||||
- Impact assessment.
|
||||
- Any suggested fix or mitigation.
|
||||
|
||||
If you are unable to use private reporting, you may open a public issue that
|
||||
contains **no technical specifics** and requests a private channel, and a
|
||||
maintainer will follow up.
|
||||
|
||||
## What to Expect
|
||||
|
||||
- We aim to acknowledge new reports within a few business days.
|
||||
- We will keep you informed as we investigate and work on a fix.
|
||||
- Once a fix is released, we are happy to credit you in the advisory (unless
|
||||
you prefer to remain anonymous).
|
||||
|
||||
Thank you for helping keep PraisonAIUI and its users safe.
|
||||
@@ -0,0 +1,43 @@
|
||||
# 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.
|
||||
+18
-4
@@ -9,6 +9,14 @@ site:
|
||||
radius: "md"
|
||||
darkMode: true
|
||||
|
||||
seo:
|
||||
siteUrl: "https://ui.praison.ai"
|
||||
titleTemplate: "%s | %s"
|
||||
defaultImage: "/icon.svg"
|
||||
robots:
|
||||
index: true
|
||||
follow: true
|
||||
|
||||
# Mintlify-style navigation: few tabs, many groups per tab
|
||||
navigation:
|
||||
tabs:
|
||||
@@ -51,6 +59,11 @@ navigation:
|
||||
- typescript/runtime
|
||||
- typescript/nextjs
|
||||
- typescript/components
|
||||
- group: "Frontend"
|
||||
icon: "layout"
|
||||
prefix: "frontend"
|
||||
pages:
|
||||
- frontend/beautifului
|
||||
- group: "Examples"
|
||||
icon: "folder-open"
|
||||
prefix: "examples"
|
||||
@@ -73,11 +86,12 @@ navigation:
|
||||
# Navbar actions
|
||||
navbar:
|
||||
primary:
|
||||
type: "github"
|
||||
href: "https://github.com/MervinPraison/PraisonAIUI"
|
||||
type: "button"
|
||||
label: "Get started"
|
||||
href: "/docs/getting-started/quickstart"
|
||||
links:
|
||||
- label: "Submit Issues"
|
||||
href: "https://github.com/MervinPraison/PraisonAIUI/issues"
|
||||
- label: "GitHub"
|
||||
href: "https://github.com/MervinPraison/PraisonAIUI"
|
||||
|
||||
# Footer
|
||||
footer:
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# Health API Reference
|
||||
|
||||
The AIUI health API provides endpoints to monitor server and provider health status.
|
||||
|
||||
## Health Status Values
|
||||
|
||||
The following status values are used throughout the health API:
|
||||
|
||||
| Status | Meaning | Color Code |
|
||||
|--------|---------|------------|
|
||||
| `"ok"` | Service is functioning normally | Green |
|
||||
| `"healthy"` | Service is functioning normally (legacy) | Green |
|
||||
| `"degraded"` | Service is partially functional | Yellow |
|
||||
| `"error"` | Service has errors | Red |
|
||||
| `"unknown"` | Status cannot be determined | Yellow |
|
||||
|
||||
> **Note:** Both `"ok"` and `"healthy"` indicate successful operation. The system accepts either value for backward compatibility.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### GET /health
|
||||
|
||||
Returns the overall server health status.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"timestamp": "2026-06-23T10:00:00Z",
|
||||
"integrated": true,
|
||||
"sdk_gaps": []
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/provider
|
||||
|
||||
Returns information about the active AI provider.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"name": "PraisonAIProvider",
|
||||
"module": "praisonaiui.providers",
|
||||
"status": "ok",
|
||||
"agents": []
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/provider/health
|
||||
|
||||
Returns detailed JSON health information from the provider gateway. Always
|
||||
responds with `Content-Type: application/json`. The base fields from the
|
||||
provider's `health()` payload are merged with the normalised fields below.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"provider": "PraisonAIProvider",
|
||||
"type": "PraisonAIProvider",
|
||||
"agents": 5,
|
||||
"detail": "ok"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `status` | string | One of the health status values above. |
|
||||
| `type` | string | Provider type (from `provider` field, falls back to class name). |
|
||||
| `agents` | integer | Count of agents reported by the provider. |
|
||||
| `detail` | string | Human-readable detail; `"ok"` for healthy providers. |
|
||||
|
||||
> **Note:** Any unmatched `/api/*` path returns a JSON `404` (never the SPA
|
||||
> HTML shell), so API clients can rely on parsing the response body.
|
||||
|
||||
## CLI Commands
|
||||
|
||||
### health-check
|
||||
|
||||
Check server health status:
|
||||
|
||||
```bash
|
||||
aiui health-check --server http://127.0.0.1:8000
|
||||
```
|
||||
|
||||
The command displays:
|
||||
- Green panel for `"ok"` or `"healthy"` status
|
||||
- Yellow panel for other statuses
|
||||
- Red error message if server is unreachable
|
||||
|
||||
### doctor
|
||||
|
||||
Run comprehensive diagnostics:
|
||||
|
||||
```bash
|
||||
aiui doctor --server http://127.0.0.1:8000
|
||||
```
|
||||
|
||||
The doctor command checks:
|
||||
1. Server Health
|
||||
2. Provider Status
|
||||
3. Gateway Status
|
||||
4. Features Loaded
|
||||
5. Config Store
|
||||
6. Datastore
|
||||
7. Channels
|
||||
|
||||
### provider status
|
||||
|
||||
Check provider health:
|
||||
|
||||
```bash
|
||||
aiui provider status --server http://127.0.0.1:8000
|
||||
```
|
||||
|
||||
## Python API
|
||||
|
||||
```python
|
||||
from praisonaiui.health_utils import is_success_status
|
||||
|
||||
# Check if a status indicates success
|
||||
if is_success_status("ok"):
|
||||
print("Service is healthy")
|
||||
|
||||
# Available success statuses
|
||||
from praisonaiui.health_utils import SUCCESS_STATUSES
|
||||
# frozenset({'ok', 'healthy'})
|
||||
```
|
||||
|
||||
## JavaScript/Dashboard
|
||||
|
||||
The dashboard automatically accepts both `"ok"` and `"healthy"` as success statuses:
|
||||
|
||||
```javascript
|
||||
const ok = d && (d.status === 'ok' || d.status === 'healthy');
|
||||
```
|
||||
@@ -227,6 +227,25 @@ aiui.set_dashboard(page_header=False) # Hide page titles
|
||||
|
||||
---
|
||||
|
||||
### `aiui.set_chat_preview(enabled, *, surface_id, width)`
|
||||
|
||||
Configure the **chat + canvas** split preview (`chat-canvas` page). The vanilla `chat` page is unchanged.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `enabled` | `bool` | `True` | Expose preview settings in `/ui-config.json` |
|
||||
| `surface_id` | `str` | `"main"` | Default A2UI surface id for the preview panel |
|
||||
| `width` | `str` | `"40%"` | CSS width of the preview column (e.g. `"38%"`, `"420px"`) |
|
||||
|
||||
```python
|
||||
aiui.set_chat_preview(enabled=True, surface_id="main", width="38%")
|
||||
aiui.set_pages(["chat-canvas", "chat", "canvas"])
|
||||
```
|
||||
|
||||
See [A2UI canvas](../features/a2ui-canvas.md).
|
||||
|
||||
---
|
||||
|
||||
### `aiui.register_theme(name, variables)`
|
||||
|
||||
Register a custom theme preset. The theme becomes available in the theme picker UI and via `/api/theme`.
|
||||
@@ -559,6 +578,19 @@ async def on_resume(session_id):
|
||||
|
||||
---
|
||||
|
||||
### `@aiui.surface_action(surface_id)`
|
||||
|
||||
Handle user actions from an A2UI surface (button clicks, form submits).
|
||||
|
||||
```python
|
||||
@aiui.surface_action("main")
|
||||
async def on_surface_action(action: dict):
|
||||
# action includes component_id, action name, data, session_id
|
||||
return {"status": "ok"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Message Functions
|
||||
|
||||
Async functions for sending messages to the user. Call inside callback handlers.
|
||||
|
||||
@@ -16,7 +16,8 @@ graph TD
|
||||
GW["WebSocketGateway<br/>REST + WebSocket<br/>Bot management"]
|
||||
end
|
||||
|
||||
CHAT -->|"AG-UI protocol<br/>(SSE events)"| AGUI
|
||||
CHAT -->|"WebSocket / SSE<br/>(RunEvent protocol)"| GW
|
||||
CHAT -.->|"AG-UI (optional)<br/>POST /agui"| AGUI
|
||||
DASH -->|"REST API<br/>(/api/channels, /api/nodes)"| GW
|
||||
```
|
||||
|
||||
@@ -110,7 +111,7 @@ sequenceDiagram
|
||||
|
||||
## Using Both Together
|
||||
|
||||
`AIUIGateway.start()` in `integration.py` mounts both layers on the same server:
|
||||
`AIUIGateway.start()` in `integration.py` mounts the gateway and dashboard REST API on one server. Chat uses the built-in WebSocket/SSE `RunEvent` protocol; AG-UI is mounted separately via `AguiFeature` when enabled:
|
||||
|
||||
```python
|
||||
from praisonaiui.integration import AIUIGateway
|
||||
@@ -121,10 +122,12 @@ agent = Agent(name="assistant", instructions="You are helpful.")
|
||||
gateway.register_agent(agent)
|
||||
|
||||
await gateway.start()
|
||||
# Chat: POST /agui → AG-UI streaming (via AGUI class)
|
||||
# Admin: GET /api/channels → Gateway REST API
|
||||
# Admin: GET /api/nodes → Gateway REST API
|
||||
# WS: /ws → WebSocket for real-time updates
|
||||
# Chat: POST /run, WS /api/chat/ws → RunEvent streaming (built-in)
|
||||
# AG-UI: POST /agui, GET /agui/status → CopilotKit-compatible SSE (AguiFeature)
|
||||
# Admin: GET /api/channels → Gateway REST API
|
||||
# Admin: GET /api/nodes → Gateway REST API
|
||||
# WS: /ws → Legacy gateway WebSocket
|
||||
# A2UI: GET/POST /api/surfaces/* → Agent-driven surfaces (SurfaceFeature)
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -108,6 +108,34 @@ class EchoProvider(BaseProvider):
|
||||
yield RunEvent(type=RunEventType.RUN_COMPLETED, content=f"Echo: {message}")
|
||||
```
|
||||
|
||||
### Inline media (images, video, files)
|
||||
|
||||
Any provider can attach rich media to chat messages using ``MESSAGE_ELEMENT``
|
||||
events. The UI renders them inline and persists them in session history.
|
||||
|
||||
```python
|
||||
class ImageProvider(BaseProvider):
|
||||
async def run(self, message, *, session_id=None, agent_name=None, **kw):
|
||||
yield RunEvent(type=RunEventType.RUN_STARTED)
|
||||
# ... call your image API (OpenAI, Stability, custom, etc.) ...
|
||||
image_url = "https://example.com/generated.png"
|
||||
yield BaseProvider.message_element_event({
|
||||
"type": "image",
|
||||
"url": image_url,
|
||||
"alt": "Generated image",
|
||||
})
|
||||
yield RunEvent(type=RunEventType.RUN_COMPLETED, content="Here is your image.")
|
||||
```
|
||||
|
||||
Tool results from PraisonAI agents are also detected automatically when they
|
||||
match OpenAI image shapes (``{"data": [{"url": "..."}]}``) or explicit element
|
||||
dicts — no provider code required.
|
||||
|
||||
Callback handlers can use ``Message.add_image()`` or ``await aiui.image(url)``
|
||||
and the default ``PraisonAIProvider`` maps those to ``MESSAGE_ELEMENT`` events.
|
||||
|
||||
Full architecture, data flows, and tests: [Image Preview in Chat](../features/image-preview-chat.md).
|
||||
|
||||
### With Tool Calls and Reasoning
|
||||
|
||||
```python
|
||||
@@ -155,7 +183,7 @@ Every event has a `type` field and optional payload fields:
|
||||
| `step` | `str` | `REASONING_STEP` |
|
||||
| `error` | `str` | `RUN_ERROR` |
|
||||
| `agent_name` | `str` | Multi-agent events |
|
||||
| `extra_data` | `dict` | Custom payload |
|
||||
| `extra_data` | `dict` | Custom payload (includes `element` for `message_element`) |
|
||||
|
||||
### Event Types (27 total)
|
||||
|
||||
@@ -178,6 +206,7 @@ Every event has a `type` field and optional payload fields:
|
||||
| `updating_memory` | Memory being updated |
|
||||
| `run_paused` | Run paused (e.g. waiting for user) |
|
||||
| `run_continued` | Run resumed |
|
||||
| `message_element` | Inline media element (image, video, file, etc.) — see [Image Preview in Chat](../features/image-preview-chat.md) |
|
||||
|
||||
=== "Team Events"
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# A2UI canvas — agent-driven UI
|
||||
|
||||
PraisonAIUI hosts **A2UI v0.9** surfaces: agents push structured UI messages to a live canvas while users chat in the same window or on a full-page workspace.
|
||||
|
||||
For dashboard extension patterns (views, plugins, layout JSON), see [Agent UI host](agent-ui-host.md).
|
||||
|
||||
## Quick start
|
||||
|
||||
```python
|
||||
import praisonaiui as aiui
|
||||
|
||||
aiui.set_style("dashboard")
|
||||
aiui.set_chat_preview(enabled=True, surface_id="main", width="38%")
|
||||
aiui.set_pages(["chat-canvas", "chat", "canvas"])
|
||||
|
||||
@aiui.page("agent-canvas", title="Agent Canvas", icon="🖼️")
|
||||
async def agent_canvas():
|
||||
return {"_surface": {"id": "main", "messages": []}}
|
||||
|
||||
@aiui.surface_action("main")
|
||||
async def on_surface_action(action: dict):
|
||||
return {"received": action}
|
||||
```
|
||||
|
||||
Run the full example: [`examples/python/30-a2ui-canvas/app.py`](../../examples/python/30-a2ui-canvas/app.py).
|
||||
|
||||
## Dashboard pages
|
||||
|
||||
| Page id | Route | Purpose |
|
||||
|---------|-------|---------|
|
||||
| `chat-canvas` | `/chat-canvas` | Split view — chat left, live A2UI preview right |
|
||||
| `chat` | `/chat` | Vanilla chat (unchanged) |
|
||||
| `canvas` | `/canvas` | Full-page surface workspace |
|
||||
|
||||
Enable the split preview with [`set_chat_preview`](../api/python.md#set_chat_previewenabled-surface_id-width). Settings appear in `/ui-config.json` as `chat.preview: { enabled, surfaceId, width }`.
|
||||
|
||||
## Agent integration
|
||||
|
||||
Agents should call **`send_a2ui_messages`** (from `praisonaiagents.tools.a2ui_tools`) with A2UI message dicts:
|
||||
|
||||
```python
|
||||
send_a2ui_messages(messages=[
|
||||
{"createSurface": {"surfaceId": "main", "catalogId": "basic"}},
|
||||
{"updateComponents": {"components": [
|
||||
{"component": "Text", "text": {"literal": "Hello"}},
|
||||
{"component": "Button", "text": {"literal": "Submit"},
|
||||
"action": {"name": "handleSubmit"}},
|
||||
]}},
|
||||
])
|
||||
```
|
||||
|
||||
Wrap the tool with **`coerce_a2ui_tool_messages`** so common LLM argument shapes still work:
|
||||
|
||||
```python
|
||||
from praisonaiui.a2ui_utils import coerce_a2ui_tool_messages
|
||||
|
||||
@tool
|
||||
def send_a2ui_messages(messages):
|
||||
coerced = coerce_a2ui_tool_messages(messages, surface_id="main")
|
||||
return sdk_send_a2ui(messages=coerced)
|
||||
```
|
||||
|
||||
`coerce_a2ui_tool_messages` accepts:
|
||||
|
||||
- A JSON string or list of message dicts
|
||||
- A dict with a `messages` key
|
||||
- A bare `components` list (wrapped in `updateComponents`)
|
||||
- A single component dict (wrapped in `updateComponents`)
|
||||
- Missing `createSurface` (inserted automatically for the given `surface_id`)
|
||||
|
||||
Tool results are detected by [`a2ui_utils`](../../src/praisonaiui/a2ui_utils.py) and ingested into the surface store; connected clients receive WebSocket `a2ui_surface` events.
|
||||
|
||||
## HTTP API
|
||||
|
||||
| Method | Path | Behaviour |
|
||||
|--------|------|-----------|
|
||||
| `GET` | `/api/surfaces` | List surfaces and message counts |
|
||||
| `GET` | `/api/surfaces/{id}` | Surface state; **200 with `{ messages: [] }`** if not created yet (empty canvas is normal) |
|
||||
| `POST` | `/api/surfaces/{id}/messages` | Append or replace messages (`replace: true` in body) |
|
||||
| `POST` | `/api/surfaces/{id}/actions` | User action from a button/component |
|
||||
| `DELETE` | `/api/surfaces/{id}` | Clear surface |
|
||||
|
||||
Realtime updates use the existing dashboard WebSocket (`a2ui_surface` payload).
|
||||
|
||||
## CLI
|
||||
|
||||
Manage surfaces against a running server (`--server` / `AIUI_SERVER`, default `http://127.0.0.1:8000`):
|
||||
|
||||
```bash
|
||||
aiui surface list
|
||||
aiui surface get main
|
||||
aiui surface get main --json
|
||||
aiui surface push main --file messages.json
|
||||
aiui surface push main --file messages.json --replace
|
||||
aiui surface clear main
|
||||
aiui surface status
|
||||
```
|
||||
|
||||
`messages.json` must contain `{ "messages": [ ... ] }`.
|
||||
|
||||
## Frontend modules
|
||||
|
||||
Shared dashboard JS (lazy-loaded with `chat-canvas`):
|
||||
|
||||
| Module | Role |
|
||||
|--------|------|
|
||||
| `surface-utils.js` | Load surface, WebSocket sync, post actions |
|
||||
| `canvas-preview.js` | Right-hand preview panel |
|
||||
| `a2ui-mapper.js` | Default DOM mapper (Text, Button, Column, Row, Card, …) |
|
||||
| `plugins/views/chat-canvas.js` | Composes chat + preview without modifying `chat.js` |
|
||||
|
||||
Override rendering: `window.aiui.registerSurfaceRenderer("main", fn)`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---------|--------------|-----|
|
||||
| Chat works but canvas stays empty | Agent did not call `send_a2ui_messages` | Strengthen instructions; wrap tool with `coerce_a2ui_tool_messages` |
|
||||
| Tool error / invalid JSON | LLM passed wrong shape | Use coercion wrapper; include schema prompt from `praisonaiagents.ui.a2ui.adapter` |
|
||||
| Preview shows raw JSON | Component type not in `a2ui-mapper` | Extend mapper or use supported components |
|
||||
| `GET /api/surfaces/main` empty | Normal before first push | Push via agent or `aiui surface push` |
|
||||
| Button click does nothing | No `@aiui.surface_action` handler | Register handler for that `surface_id` |
|
||||
|
||||
## Related
|
||||
|
||||
- [Agent UI host](agent-ui-host.md) — `registerSurfaceRenderer`, layout tiers
|
||||
- [Python API — `set_chat_preview`](../api/python.md#set_chat_previewenabled-surface_id-width)
|
||||
- [Python API — `@aiui.surface_action`](../api/python.md#aiuisurface_actionsurface_id)
|
||||
@@ -0,0 +1,223 @@
|
||||
# Agent UI host — modular dashboard shell
|
||||
|
||||
PraisonAIUI can host any AI agent backend behind a **modular dashboard shell**: pages, views, layout components, optional plugins, and branding — without forking the frontend.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
YAML / Python config → server (pages API, plugins.json) → dashboard.js shell
|
||||
↓
|
||||
registerView / BUILTIN_VIEWS / layout JSON / registerComponent
|
||||
↓
|
||||
renderComponent() + theme (--db-*)
|
||||
```
|
||||
|
||||
**Extension priority** (first match wins):
|
||||
|
||||
1. `window.aiui.registerView(pageId, …)`
|
||||
2. Dashboard manifest plugins (`dashboard-plugins/*/manifest.json`)
|
||||
3. Built-in view modules (`plugins/views/*.js` via `BUILTIN_VIEWS`)
|
||||
4. Server `aiui.layout()` JSON from `@aiui.page()`
|
||||
5. `window.aiui.registerComponent(type, …)` inside `renderComponent`
|
||||
6. Generic JSON fallback viewer
|
||||
|
||||
## Video Studio
|
||||
|
||||
For YAML scene authoring with an external PraisonAI Video engine, see [Video Studio](video-studio.md).
|
||||
|
||||
## Quick start (external agent)
|
||||
|
||||
```python
|
||||
import praisonaiui as aiui
|
||||
from praisonaiui.server import create_app
|
||||
|
||||
aiui.set_style("dashboard")
|
||||
aiui.set_branding(title="Acme Agents", logo="A")
|
||||
aiui.set_pages(["chat", "tasks", "sessions", "config"])
|
||||
aiui.set_dashboard(modules=["jobs"], sidebar=True, page_header=True)
|
||||
|
||||
@aiui.page("tasks", title="Tasks", icon="📋", group="Work")
|
||||
async def tasks_page():
|
||||
return aiui.layout([
|
||||
aiui.board(columns=[
|
||||
{"id": "todo", "title": "Todo", "cards": [aiui.card("Review PR", footer="agent-a")]},
|
||||
{"id": "done", "title": "Done", "cards": [aiui.card("Ship feature")]},
|
||||
]),
|
||||
])
|
||||
|
||||
app = create_app()
|
||||
```
|
||||
|
||||
See [`examples/python/external-agent-dashboard/app.py`](../../examples/python/external-agent-dashboard/app.py).
|
||||
|
||||
## `window.aiui` extension API
|
||||
|
||||
| API | Purpose |
|
||||
|-----|---------|
|
||||
| `version` | Shell protocol version (`'1'`) |
|
||||
| `registerView(pageId, renderFn, cleanup?)` | Replace or add a full page renderer |
|
||||
| `registerComponent(type, renderFn)` | Override a layout component type |
|
||||
| `registerSurfaceRenderer(surfaceId, renderFn)` | Custom A2UI surface renderer |
|
||||
| `selectPage(pageId)` | Navigate programmatically (e.g. open full canvas) |
|
||||
| `registerSlot(name, renderFn)` | Inject DOM into named slots |
|
||||
| `sdk.fetchJSON(url, opts?)` | Authenticated fetch helper |
|
||||
| `sdk.el(tag, attrs?, children?)` | Small DOM builder |
|
||||
| `sdk.themeVar('--db-*', fallback?)` | Read theme CSS variables |
|
||||
| `sdk.createBoard(root, { fetch, pollMs? })` | Live board with optional polling |
|
||||
|
||||
### Slots
|
||||
|
||||
| Slot id | Where it renders |
|
||||
|---------|------------------|
|
||||
| `shell:sidebar:footer` | Bottom of sidebar (default: health hint) |
|
||||
| `page:{pageId}:toolbar` | Right side of page header |
|
||||
|
||||
Register only what you need; omit slots you do not use.
|
||||
|
||||
## Layout components (Python → JS)
|
||||
|
||||
Define pages with `@aiui.page()` returning `aiui.layout([...])`. Types are implemented once in [`ui.py`](../../src/praisonaiui/ui.py) and [`dashboard.js`](../../src/praisonaiui/templates/frontend/plugins/dashboard.js).
|
||||
|
||||
**Kanban / board:** use `aiui.board(columns=[...])`. Columns contain `id`, `title`, and `cards` (from `aiui.card()` or dicts). The shell renders via `renderBoard`, composing existing card renderers (DRY).
|
||||
|
||||
## A2UI surfaces (agent-driven UI)
|
||||
|
||||
Three tiers work together:
|
||||
|
||||
| Tier | Mechanism | Use case |
|
||||
|------|-----------|----------|
|
||||
| Dashboard | `aiui.layout()` → `_components` | Python-authored pages |
|
||||
| A2UI | `send_a2ui_messages` → `SurfaceHost` | Agent-generated UI in chat/canvas |
|
||||
| AG-UI | `POST /agui` SSE | CopilotKit / external clients |
|
||||
|
||||
Register an empty canvas page:
|
||||
|
||||
```python
|
||||
@aiui.page("agent-canvas", title="Agent Canvas", icon="🖼️")
|
||||
async def agent_canvas():
|
||||
return {"_surface": {"id": "main", "messages": []}}
|
||||
```
|
||||
|
||||
Or use `@aiui.surface_action("main")` for button callbacks.
|
||||
|
||||
**Full guide:** [A2UI canvas](a2ui-canvas.md) — chat-canvas split view, `send_a2ui_messages`, HTTP/CLI, `coerce_a2ui_tool_messages`, troubleshooting. Example: [`examples/python/30-a2ui-canvas/`](../../examples/python/30-a2ui-canvas/app.py).
|
||||
|
||||
## Optional dashboard modules
|
||||
|
||||
Load heavy built-ins only when listed:
|
||||
|
||||
```python
|
||||
aiui.set_dashboard(modules=["jobs", "auth", "api"])
|
||||
```
|
||||
|
||||
```yaml
|
||||
dashboard:
|
||||
modules: [jobs]
|
||||
```
|
||||
|
||||
This adds `jobs.js`, `auth.js`, or `api.js` to the plugin chain and maps them in `BUILTIN_VIEWS`. `feedback` is always available as a built-in view when the page is enabled.
|
||||
|
||||
## Third-party dashboard plugins
|
||||
|
||||
Place folders under:
|
||||
|
||||
- `~/.praisonai/dashboard-plugins/{name}/`
|
||||
- Or `src/praisonaiui/templates/frontend/dashboard-plugins/{name}/` (shipped examples)
|
||||
|
||||
Each plugin needs `manifest.json` + `index.js` (optional `styles.css`):
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-board",
|
||||
"label": "My board",
|
||||
"icon": "📋",
|
||||
"tab": { "path": "/my-board" },
|
||||
"entry": "index.js"
|
||||
}
|
||||
```
|
||||
|
||||
The server exposes:
|
||||
|
||||
- `GET /api/dashboard/plugins` — manifest list
|
||||
- `GET /dashboard-plugins/{name}/{path}` — static assets
|
||||
|
||||
Plugins register views in `index.js`:
|
||||
|
||||
```javascript
|
||||
window.aiui.registerView('my-board', async (container) => {
|
||||
window.aiui.sdk.createBoard(container, {
|
||||
fetch: async () => ({ columns: [...] }),
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Sample: [`sample-board`](../../src/praisonaiui/templates/frontend/dashboard-plugins/sample-board/).
|
||||
|
||||
## Agent plugins vs dashboard plugins
|
||||
|
||||
| System | Location | Purpose |
|
||||
|--------|----------|---------|
|
||||
| Agent plugins | `~/.praisonai/plugins/` | Python agent extensions |
|
||||
| Dashboard plugins | `~/.praisonai/dashboard-plugins/` | JS UI tabs |
|
||||
|
||||
See [PraisonAI package integration](praisonai-package-integration.md) for backend wiring, jobs API, and deployment.
|
||||
|
||||
## Granular control — opt-in / opt-out
|
||||
|
||||
| What you control | How | Example |
|
||||
|------------------|-----|---------|
|
||||
| UI mode | `aiui.set_style(...)` | `"dashboard"`, `"chat"`, `"docs"` |
|
||||
| Sidebar pages | `aiui.set_pages([ids])` whitelist | `["chat", "sessions"]` |
|
||||
| Hide built-ins | YAML `pages.disabled: [...]` | Blacklist in server |
|
||||
| Sidebar on/off | `set_dashboard(sidebar=False)` | Chat-only full width |
|
||||
| Page title bar | `set_dashboard(page_header=False)` | Cleaner surface |
|
||||
| Optional modules | `set_dashboard(modules=[...])` | `["jobs"]` only |
|
||||
| Third-party tabs | Install chosen `dashboard-plugins/` folders | Manifest per plugin |
|
||||
| Shell chrome | `registerSlot` | Header/footer widgets |
|
||||
| Look only | `set_theme`, `set_branding`, `set_custom_css` | White-label |
|
||||
| Replace one page | `registerView(pageId, ...)` | Override built-in |
|
||||
| Board UI | `aiui.board()` or plugin | Not in default nav |
|
||||
|
||||
**Note:** `@aiui.page()` custom pages are **not** filtered by `set_pages()` — only built-in dashboard pages are. Remove custom pages from code to hide them.
|
||||
|
||||
### Example profiles
|
||||
|
||||
```python
|
||||
# Chat-only
|
||||
aiui.set_style("dashboard")
|
||||
aiui.set_pages(["chat"])
|
||||
aiui.set_dashboard(sidebar=False, page_header=False)
|
||||
|
||||
# Ops console — no chat
|
||||
aiui.set_pages(["sessions", "agents", "logs", "config", "usage", "approvals"])
|
||||
```
|
||||
|
||||
```yaml
|
||||
style: dashboard
|
||||
pages:
|
||||
enabled: [chat, agents, memory, sessions]
|
||||
dashboard:
|
||||
sidebar: true
|
||||
pageHeader: true
|
||||
modules: [jobs]
|
||||
plugin_dirs: [/opt/my-agent/plugins]
|
||||
```
|
||||
|
||||
CLI: `aiui pages ids` lists built-in page ids for whitelists.
|
||||
|
||||
## DRY rules for contributors
|
||||
|
||||
1. Add new layout types in **`ui.py`** and one `case` in **`dashboard.js`**.
|
||||
2. Page-specific UI belongs in **`plugins/views/*.js`**, delegating to shared modules (`jobs.js`, etc.) via thin wrappers.
|
||||
3. Reuse **`_helpers.js`** (`pageToolbar`, `filterChips`, `searchInput`, `modalShell`, `toast`).
|
||||
4. Boards compose **`card`** / **`columns`** — do not duplicate card markup in plugins.
|
||||
|
||||
## Related files
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| [`praisonai-package-integration.md`](praisonai-package-integration.md) | Praison backend wiring |
|
||||
| [`server.py`](../../src/praisonaiui/server.py) | Pages API, `set_dashboard`, plugin chain |
|
||||
| [`dashboard_plugins.py`](../../src/praisonaiui/dashboard_plugins.py) | Manifest discovery and static routes |
|
||||
| [`dashboard.js`](../../src/praisonaiui/templates/frontend/plugins/dashboard.js) | Shell, registries, renderers |
|
||||
| [`schema/models.py`](../../src/praisonaiui/schema/models.py) | `DashboardConfig.modules`, `plugin_dirs` |
|
||||
@@ -0,0 +1,25 @@
|
||||
# Backend Integration Contract
|
||||
|
||||
PraisonAIUI supports a four-layer integration model so standalone mode and SDK-integrated mode can share the same feature APIs.
|
||||
|
||||
## Four Layers
|
||||
|
||||
1. **Feature API layer**
|
||||
Endpoints live in feature modules and expose stable HTTP/CLI behaviour.
|
||||
2. **Feature manager layer**
|
||||
Each feature manager keeps local fallback logic for standalone operation.
|
||||
3. **Backend factory layer**
|
||||
`praisonaiui.backends` provides optional injected factories (`get_workflow_runner()`, `get_hooks_lister()`, `get_usage_query()`, etc.).
|
||||
4. **SDK/runtime layer**
|
||||
PraisonAI or praisonaiagents implementations are used when injected or importable.
|
||||
|
||||
## Injection Contract
|
||||
|
||||
- Injected backends are optional and must be callable.
|
||||
- Features must keep local fallbacks when injected backends are absent or fail.
|
||||
- Backend call signatures should remain minimal and dict/list based.
|
||||
- Integration should not introduce hard imports in feature modules when a fallback already exists.
|
||||
|
||||
## Operational Rule
|
||||
|
||||
When integrated, wrappers should inject backend callables at startup; when standalone, features should run without any backend injection.
|
||||
@@ -0,0 +1,56 @@
|
||||
# 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.
|
||||
@@ -129,7 +129,7 @@
|
||||
| Android app | 🔴 | 🟢 | |
|
||||
| Voice Wake (wake word) | 🔴 | 🟢 | macOS + iOS |
|
||||
| Talk Mode (voice chat) | 🔴 | 🟢 | ElevenLabs + system TTS |
|
||||
| Canvas (visual workspace) | 🔴 | 🟢 A2UI | Agent-driven UI |
|
||||
| Canvas (visual workspace) | 🟡 A2UI surfaces | 🟢 A2UI | Agent-driven UI via `send_a2ui_messages` |
|
||||
| Device pairing | 🔴 | 🟢 Bonjour | iOS/Android → Gateway |
|
||||
| PWA support | 🔴 | 🔴 | Neither has PWA |
|
||||
| i18n / localization | 🔴 | 🔴 | Neither has i18n |
|
||||
@@ -187,7 +187,7 @@
|
||||
| **Skills marketplace** | 13,000+ vs 0 | 🔴 Critical — massive ecosystem moat |
|
||||
| **Native apps** | macOS/iOS/Android vs web-only | 🟡 High — device presence |
|
||||
| **Voice** | Wake word + Talk Mode vs none | 🟡 High — hands-free interaction |
|
||||
| **Canvas (A2UI)** | Agent-driven visual workspace vs none | 🟡 Medium |
|
||||
| **Canvas (A2UI)** | Agent-driven visual workspace vs partial parity | 🟡 Medium |
|
||||
| **Agent-to-agent** | Sessions-based messaging vs none | 🟡 Medium |
|
||||
|
||||
### Strategic Positioning
|
||||
|
||||
@@ -49,7 +49,7 @@ services:
|
||||
volumes:
|
||||
- aiui-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8082/health"]
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8082/health/live"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -81,7 +81,7 @@ curl -H "Authorization: Bearer <api-key>" http://localhost:8082/api/config
|
||||
```
|
||||
|
||||
**Exempt paths** (always accessible without auth):
|
||||
- `/health`, `/api/health` — health checks
|
||||
- `/health`, `/health/live`, `/health/ready`, `/api/health` — health checks
|
||||
- `/api/auth/login`, `/login` — login endpoints
|
||||
- `/api/protocol`, `/api/protocol/negotiate` — protocol discovery
|
||||
|
||||
@@ -146,8 +146,36 @@ This affects:
|
||||
### Health Endpoint
|
||||
|
||||
```bash
|
||||
# Fast liveness probe (<500ms) - use for load balancers and K8s livenessProbe
|
||||
curl http://localhost:8082/health/live
|
||||
# Returns: {"status": "ok", "timestamp": "2024-..."}
|
||||
|
||||
# Readiness probe with parallel feature checks - use for K8s readinessProbe
|
||||
curl http://localhost:8082/health/ready
|
||||
|
||||
# Deep diagnostics (parallel feature checks, deep by default)
|
||||
curl http://localhost:8082/health
|
||||
# Returns: {"status": "healthy", "timestamp": "2024-..."}
|
||||
# Pass ?deep=false for an immediate liveness response
|
||||
```
|
||||
|
||||
### Kubernetes Probes
|
||||
|
||||
Use the fast liveness endpoint for probes to avoid timeouts:
|
||||
|
||||
```yaml
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health/live
|
||||
port: 8082
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 1
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health/ready
|
||||
port: 8082
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
```
|
||||
|
||||
### Docker HEALTHCHECK
|
||||
@@ -156,17 +184,17 @@ The Dockerfile includes a built-in health check:
|
||||
|
||||
```dockerfile
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:${AIUI_PORT}/health || exit 1
|
||||
CMD curl -f http://localhost:${AIUI_PORT}/health/live || exit 1
|
||||
```
|
||||
|
||||
### CLI Health Commands
|
||||
|
||||
```bash
|
||||
# Quick health check
|
||||
aiui health --server http://localhost:8082
|
||||
aiui health-check --server http://localhost:8082
|
||||
|
||||
# Detailed per-feature health
|
||||
aiui health --server http://localhost:8082 --detailed
|
||||
# Detailed per-feature health
|
||||
aiui health-check --server http://localhost:8082 --detailed
|
||||
|
||||
# Full diagnostic (7 checks)
|
||||
aiui doctor --server http://localhost:8082
|
||||
@@ -199,7 +227,7 @@ curl -H "X-API-Key: <key>" http://localhost:8082/api/config
|
||||
|
||||
- **Internal networks**: Use `AUTH_ENFORCE=false` if behind a trusted reverse proxy
|
||||
- **Public exposure**: Always set `AUTH_ENFORCE=true` and use HTTPS
|
||||
- **Load balancers**: Health check path is `/health` (exempt from auth)
|
||||
- **Load balancers**: Health check path is `/health/live` (fast, exempt from auth)
|
||||
|
||||
## Managed Hosting
|
||||
|
||||
@@ -209,7 +237,9 @@ Hosting platforms should expose these endpoints:
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|----------|---------|
|
||||
| `/health` | Health check (returns `{"status": "healthy"}`) |
|
||||
| `/health/live` | Fast liveness probe (returns `{"status": "ok"}`) |
|
||||
| `/health/ready` | Readiness probe (parallel feature checks) |
|
||||
| `/health` | Deep health (parallel feature checks; `?deep=false` for liveness) |
|
||||
| `/api/protocol` | Protocol version and capabilities |
|
||||
| `/api/protocol/negotiate` | Capability negotiation |
|
||||
| `/api/features` | List of registered features |
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
# Image Preview in Chat
|
||||
|
||||
Protocol-driven inline media for agent-generated images (and other element types) during chat streaming. Any backend that implements `BaseProvider` can emit images without PraisonAI-specific code in chat or the frontend.
|
||||
|
||||
## Problem This Solves
|
||||
|
||||
Before this feature, PraisonAIUI could **render** images (`Message.add_image`, `ElementRenderer`) but the **wire protocol dropped media** during agent runs. ImageAgent / tool results appeared as JSON in the tool-call panel instead of inline `<img>` previews.
|
||||
|
||||
## Architecture
|
||||
|
||||
Modular layers — same pattern as [A2UI surfaces](./agent-ui-host.md):
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Backend as Provider_or_Tool
|
||||
participant Detect as media_utils.py
|
||||
participant Protocol as RunEvent_MESSAGE_ELEMENT
|
||||
participant Chat as chat.py
|
||||
participant UI as chat.js_React
|
||||
|
||||
Backend->>Detect: tool result or callback payload
|
||||
Detect->>Protocol: normalised element dict
|
||||
Protocol->>Chat: broadcast message_element
|
||||
Chat->>UI: WebSocket / SSE frame
|
||||
UI->>UI: appendMediaElement / ElementRenderer
|
||||
Chat->>Chat: persist elements in session history
|
||||
```
|
||||
|
||||
### Layer responsibilities
|
||||
|
||||
| Layer | Module | Backend-agnostic? |
|
||||
|-------|--------|-------------------|
|
||||
| **Detection** | [`media_utils.py`](../../src/praisonaiui/media_utils.py) | Yes — normalises OpenAI, URL, b64, element dicts |
|
||||
| **Protocol** | [`provider.py`](../../src/praisonaiui/provider.py) — `RunEventType.MESSAGE_ELEMENT` | Yes |
|
||||
| **Adapter** | [`providers/__init__.py`](../../src/praisonaiui/providers/__init__.py) — PraisonAI bridge only | No — maps SDK/callback queue → protocol |
|
||||
| **Transport** | [`chat.py`](../../src/praisonaiui/features/chat.py) — broadcast + persist | Yes |
|
||||
| **Storage** | [`attachments.py`](../../src/praisonaiui/features/attachments.py) — `GET /api/chat/media/{id}` | Yes — reuses attachment store for b64 |
|
||||
| **UI** | [`chat.js`](../../src/praisonaiui/templates/frontend/plugins/views/chat.js), [`ElementRenderer`](../../src/frontend/src/chat/MultimediaElements.tsx) | Yes — renders `{type, url, alt}` |
|
||||
|
||||
Detection and UI do **not** import PraisonAI. Only the provider adapter knows ImageAgent / OpenAI `{data: [{url}]}` shapes.
|
||||
|
||||
## Wire protocol
|
||||
|
||||
### Event type
|
||||
|
||||
```
|
||||
type: "message_element"
|
||||
element: { "type": "image", "url": "...", "alt": "..." }
|
||||
session_id: "..."
|
||||
run_id: "..."
|
||||
```
|
||||
|
||||
Defined in Python as `RunEventType.MESSAGE_ELEMENT` and mirrored in [`types.ts`](../../src/frontend/src/types.ts).
|
||||
|
||||
### Helper (any provider)
|
||||
|
||||
```python
|
||||
from praisonaiui.provider import BaseProvider, RunEvent, RunEventType
|
||||
|
||||
yield BaseProvider.message_element_event({
|
||||
"type": "image",
|
||||
"url": "https://example.com/out.png",
|
||||
"alt": "Generated image",
|
||||
})
|
||||
```
|
||||
|
||||
Element shape reuses the existing [message element schema](./message-elements.md) (`image`, `pdf`, `video`, `audio`, `file`, `code`).
|
||||
|
||||
## Data flow paths
|
||||
|
||||
### 1. Custom provider (swappable backend)
|
||||
|
||||
```python
|
||||
import praisonaiui as aiui
|
||||
from praisonaiui.provider import BaseProvider, RunEvent, RunEventType
|
||||
|
||||
class MyImageProvider(BaseProvider):
|
||||
async def run(self, message, *, session_id=None, agent_name=None, **kw):
|
||||
yield RunEvent(type=RunEventType.RUN_STARTED)
|
||||
# Call any image API here (OpenAI, Stability, local, etc.)
|
||||
yield BaseProvider.message_element_event({
|
||||
"type": "image",
|
||||
"url": image_url,
|
||||
"alt": "Generated image",
|
||||
})
|
||||
yield RunEvent(type=RunEventType.RUN_COMPLETED, content="Here is your image.")
|
||||
|
||||
aiui.set_provider(MyImageProvider())
|
||||
```
|
||||
|
||||
Example app: [`examples/python/30-image-preview/app.py`](../../examples/python/30-image-preview/app.py).
|
||||
|
||||
### 2. Callback handler (`@aiui.reply`)
|
||||
|
||||
```python
|
||||
import praisonaiui as aiui
|
||||
from praisonaiui import Message
|
||||
|
||||
@aiui.reply
|
||||
async def on_message(msg):
|
||||
m = Message(content="Here is your image:")
|
||||
m.add_image("https://example.com/chart.png", alt="Chart")
|
||||
await m.send()
|
||||
```
|
||||
|
||||
`PraisonAIProvider` maps queue events (`image`, `message` + `elements`) → `MESSAGE_ELEMENT`.
|
||||
|
||||
### 3. Tool results (automatic detection)
|
||||
|
||||
When a tool completes, `tool_completed_extra()` in [`a2ui_utils.py`](../../src/praisonaiui/a2ui_utils.py) calls `build_media_extra()` from `media_utils`. Supported shapes:
|
||||
|
||||
| Input shape | Example |
|
||||
|-------------|---------|
|
||||
| OpenAI / ImageAgent | `{"data": [{"url": "...", "b64_json": "...", "revised_prompt": "..."}]}` |
|
||||
| Explicit element | `{"type": "image", "url": "..."}` |
|
||||
| Plain URL string | `"https://cdn.example.com/out.png"` |
|
||||
| Data URL | `"data:image/png;base64,..."` |
|
||||
| Nested elements | `{"elements": [{"type": "video", "url": "..."}]}` |
|
||||
|
||||
`chat.py` broadcasts `message_element` for each detected element and attaches `elements` to the persisted assistant message.
|
||||
|
||||
### 4. ImageAgent direct mode
|
||||
|
||||
If the registered agent's `chat()` returns an OpenAI-shaped dict, `PraisonAIProvider._run_direct_mode` runs `extract_media_elements()` and emits `MESSAGE_ELEMENT` events before `RUN_COMPLETED`.
|
||||
|
||||
## Base64 storage
|
||||
|
||||
Large `b64_json` payloads are **not** stored inline in message history. They are decoded and saved via `AttachmentManager`, then served at:
|
||||
|
||||
```
|
||||
GET /api/chat/media/{attachment_id}
|
||||
```
|
||||
|
||||
If storage fails, the system falls back to a `data:image/png;base64,...` URL.
|
||||
|
||||
## Frontend rendering
|
||||
|
||||
| Surface | Handler |
|
||||
|---------|---------|
|
||||
| Dashboard chat (template) | `chat.js` — `case 'message_element': appendMediaElement()` |
|
||||
| React chat | `streamingStore.ts` — `pendingElements` + `ElementRenderer` |
|
||||
| Tool call panel | Thumbnail from `toolCall.elements` when expanded |
|
||||
| Session reload | `elements` on messages from `/api/chat/history` and `/sessions/{id}/runs` |
|
||||
|
||||
See also [Message Elements](./message-elements.md) for element properties and display modes.
|
||||
|
||||
## Deduplication
|
||||
|
||||
`_collect_element()` in `chat.py` dedupes by URL so the same image is not stored twice when both a `MESSAGE_ELEMENT` event and a tool completion carry the same URL.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **REST image generation** (`POST /v1/images/generations`) — separate OpenAI-compat endpoint; not wired into chat streaming
|
||||
- **Canvas / A2UI surfaces** — separate feature for agent-driven UI catalogs
|
||||
- **Auto-registering ImageAgent** — agents are configured explicitly by the user
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
pytest tests/unit/test_media_utils.py \
|
||||
tests/integration/test_chat_image_preview.py -v -o addopts=
|
||||
```
|
||||
|
||||
Coverage includes: detection normalisation, b64 fallback, provider callback bridge, `_run_and_broadcast` persistence, media serve route, ImageAgent dict responses, URL dedup.
|
||||
|
||||
## Related docs
|
||||
|
||||
- [Providers](../concepts/providers.md) — provider protocol and inline media snippet
|
||||
- [Message Elements](./message-elements.md) — element types and Python API
|
||||
- [Attachments](./attachments.md) — user file uploads (separate from generated media serve path)
|
||||
- [Gateway Chat](./gateway-chat.md) — WebSocket event flow
|
||||
@@ -0,0 +1,69 @@
|
||||
# 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 |
|
||||
@@ -172,4 +172,10 @@ The new element system maintains backward compatibility with existing message pr
|
||||
- `message.files` (still supported)
|
||||
- New `message.elements` takes precedence when both are present
|
||||
|
||||
This ensures existing code continues to work while providing a migration path to the new standardized element system.
|
||||
This ensures existing code continues to work while providing a migration path to the new standardized element system.
|
||||
|
||||
## Agent-generated images (streaming)
|
||||
|
||||
When an agent or tool **creates** an image during a chat run, the UI receives `message_element` events over WebSocket/SSE and renders them inline. This uses the same element dict shape as above but is driven by the [RunEvent protocol](../concepts/providers.md), not only `Message.send()`.
|
||||
|
||||
See **[Image Preview in Chat](./image-preview-chat.md)** for the full architecture: detection (`media_utils`), provider bridge, persistence, and swappable backends.
|
||||
@@ -0,0 +1,201 @@
|
||||
# PraisonAI package integration
|
||||
|
||||
How **PraisonAIUI** (`aiui`) connects to **`praisonai-package`** — the agent runtime, gateway, and CLI. For generic UI hosting see [Agent UI host](agent-ui-host.md).
|
||||
|
||||
## Division of labour
|
||||
|
||||
| Layer | Repo | Role |
|
||||
|-------|------|------|
|
||||
| Agent runtime | `praisonai-agents` | `Agent`, tools, memory, streaming |
|
||||
| Gateway, jobs, CLI | `praisonai` | WebSocket `/ws`, `/api/v1/runs`, `praisonai claw` |
|
||||
| Dashboard UI | **PraisonAIUI** | `dashboard.js`, `@aiui.page`, layout protocol |
|
||||
| Work tracking | `praisonai-platform` | Issues API (optional board data source) |
|
||||
|
||||
Canonical app: `praisonai-package` → `src/praisonai/praisonai/claw/default_app.py`.
|
||||
|
||||
## Deployment shapes
|
||||
|
||||
### 1. Single process — `praisonai claw` (recommended)
|
||||
|
||||
```bash
|
||||
praisonai claw # → aiui run claw/default_app.py, default :8082
|
||||
```
|
||||
|
||||
```text
|
||||
Browser → create_app() (same process)
|
||||
→ PraisonAISessionDataStore (sessions)
|
||||
→ PraisonAIProvider (@aiui.reply / agents)
|
||||
→ Built-in dashboard views
|
||||
```
|
||||
|
||||
Use when: local dev, full dashboard, agents registered in Python.
|
||||
|
||||
### 2. Unified — `AIUIGateway`
|
||||
|
||||
```python
|
||||
from praisonaiui.integration import AIUIGateway
|
||||
gateway = AIUIGateway(port=8080)
|
||||
gateway.register_agent(agent)
|
||||
await gateway.start() # create_app() + WebSocket /ws
|
||||
```
|
||||
|
||||
Use when: you need **WebSocket gateway protocol** and dashboard on **one port**.
|
||||
|
||||
### 3. Split — gateway + UI
|
||||
|
||||
| Service | Default port | Endpoints |
|
||||
|---------|--------------|-----------|
|
||||
| `praisonai gateway start` | 8765 | `ws://…/ws`, `/health`, `/info` |
|
||||
| `praisonai claw` or `aiui run` | 8082 | Dashboard, `/run` SSE, `/api/*` |
|
||||
|
||||
Use when: gateway runs headless; UI is a separate process. Point chat at gateway via provider config or run `AIUIGateway` instead.
|
||||
|
||||
## Wiring checklist
|
||||
|
||||
```python
|
||||
import praisonaiui as aiui
|
||||
from praisonai.ui._aiui_datastore import PraisonAISessionDataStore
|
||||
|
||||
aiui.set_datastore(PraisonAISessionDataStore())
|
||||
aiui.set_style("dashboard")
|
||||
aiui.set_pages(["chat", "agents", "sessions", "config"])
|
||||
aiui.set_dashboard(modules=["jobs"], sidebar=True, page_header=True)
|
||||
|
||||
# Optional: point jobs UI at package jobs server paths
|
||||
aiui.set_jobs_api(api_base="/api/v1/runs", backend="praisonai")
|
||||
```
|
||||
|
||||
Or via host bootstrap:
|
||||
|
||||
```python
|
||||
from praisonai.integration.host_app import configure_host, create_host_app
|
||||
|
||||
configure_host(pages=[...], modules=["jobs"], agents=[...])
|
||||
app = create_host_app()
|
||||
```
|
||||
|
||||
## API surface
|
||||
|
||||
### Chat and agents (dashboard)
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|----------|---------|
|
||||
| `POST /run` | Stream agent run (SSE) |
|
||||
| `GET /sessions`, `POST /sessions` | Session CRUD |
|
||||
| `GET /agents`, `/api/agents/definitions` | Agent list / CRUD |
|
||||
| `GET /api/health` | Sidebar health slot |
|
||||
|
||||
### Async jobs — two paths
|
||||
|
||||
| Backend | Base path | When |
|
||||
|---------|-----------|------|
|
||||
| **aiui** `JobsFeature` | `/api/jobs` | Default; in-process job store |
|
||||
| **praisonai** jobs server | `/api/v1/runs` | Standalone `praisonai jobs` / FastAPI router |
|
||||
|
||||
Configure the dashboard jobs view:
|
||||
|
||||
```python
|
||||
aiui.set_jobs_api(api_base="/api/jobs", backend="aiui") # default
|
||||
aiui.set_jobs_api(api_base="/api/v1/runs", backend="praisonai")
|
||||
```
|
||||
|
||||
`/ui-config.json` exposes `jobs: { apiBase, backend }` for `jobs.js`.
|
||||
|
||||
**Response shapes:** both return `{ jobs, total }`. Praison uses `job_id`; aiui uses `id`. The jobs view normalises either field.
|
||||
|
||||
| Action | aiui | praisonai |
|
||||
|--------|------|-----------|
|
||||
| List | `GET /api/jobs` | `GET /api/v1/runs` |
|
||||
| Detail | `GET /api/jobs/{id}` | `GET /api/v1/runs/{id}` |
|
||||
| Cancel | `POST /api/jobs/{id}/cancel` | `POST /api/v1/runs/{id}/cancel` |
|
||||
| Stream | `GET /api/jobs/{id}/stream` | `GET /api/v1/runs/{id}/stream` |
|
||||
| Delete | `DELETE /api/jobs/{id}` | Not on package router |
|
||||
|
||||
**Split jobs server (optional):** proxy aiui `/api/jobs/*` to an external jobs host:
|
||||
|
||||
```python
|
||||
aiui.set_jobs_proxy("http://127.0.0.1:9000") # forwards to /api/v1/runs/*
|
||||
aiui.set_jobs_api(api_base="/api/jobs", backend="aiui")
|
||||
```
|
||||
|
||||
Use when the dashboard and jobs server run on different ports. Prefer `set_jobs_backend("praisonai")` when the browser can call `/api/v1/runs` on the same origin.
|
||||
|
||||
### REST invoke (automation)
|
||||
|
||||
| Path | Use |
|
||||
|------|-----|
|
||||
| `POST /api/v1/agents/{id}/invoke` | Sync invoke (n8n, scripts) — **package** `agent_invoke.py` |
|
||||
| `POST /run` | Streaming chat — **dashboard** chat page |
|
||||
|
||||
Same agents if registered on both surfaces; different protocols.
|
||||
|
||||
### Discovery (package serve)
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|----------|---------|
|
||||
| `GET /__praisonai__/discovery` | Serve-mode capability discovery |
|
||||
| `GET /health`, `GET /info` | Gateway liveness and metadata |
|
||||
| `GET /api/gateway/status` | aiui gateway feature status |
|
||||
|
||||
See **Feature Explorer** page in dashboard for live probes.
|
||||
|
||||
## Parallel UI protocols (not dashboard replacements)
|
||||
|
||||
Under `praisonaiagents/ui/`:
|
||||
|
||||
| Protocol | Purpose |
|
||||
|----------|---------|
|
||||
| **A2A** | Agent cards, `/.well-known/agent.json`, external agent hosts |
|
||||
| **AG-UI** | CopilotKit-style event stream (`POST /agui`) |
|
||||
| **A2UI** | Declarative surfaces in chat/canvas |
|
||||
|
||||
Dashboard shell remains the ops console for `praisonai claw`. Use A2A/AG-UI/A2UI when embedding in other products.
|
||||
|
||||
## Two plugin systems
|
||||
|
||||
| System | Location | Purpose |
|
||||
|--------|----------|---------|
|
||||
| **Agent plugins** | `~/.praisonai/plugins/` | Python agent extensions (`praisonaiagents.plugins`) |
|
||||
| **Dashboard plugins** | `~/.praisonai/dashboard-plugins/` | JS tabs + `manifest.json` for aiui shell |
|
||||
|
||||
Do not mix folders. Agent plugins extend runtime; dashboard plugins extend UI tabs only.
|
||||
|
||||
## Optional modules
|
||||
|
||||
```python
|
||||
aiui.set_dashboard(modules=["jobs", "auth", "api"])
|
||||
```
|
||||
|
||||
Loads `jobs.js`, `auth.js`, `api.js` into the plugin chain and maps them in `BUILTIN_VIEWS`. Enable only what your deployment exposes.
|
||||
|
||||
## Board / work tracking
|
||||
|
||||
Neither repo ships Hermes-style Kanban. Options:
|
||||
|
||||
1. **`aiui.board()`** — columns from `@aiui.page` handler (jobs by status, custom data).
|
||||
2. **`praisonai-platform` issues** — map issue `status` → board columns (see `examples/python/platform-board/`). Set `PRAISONAI_PLATFORM_URL` and `PRAISONAI_PLATFORM_WORKSPACE_ID` (default workspace `default`); issues are fetched from `GET /api/v1/workspaces/{workspace_id}/issues`.
|
||||
3. **`dashboard-plugins`** — rich JS via `registerView` + `sdk.createBoard`.
|
||||
|
||||
Advanced board UX (drag-drop, WebSocket sync) is **deferred** — use polling via `sdk.createBoard({ pollMs })` or a custom plugin.
|
||||
|
||||
## Smoke checklist
|
||||
|
||||
After `praisonai claw` or `aiui run` with package bridges:
|
||||
|
||||
- [ ] `GET /api/health` → sidebar footer shows healthy
|
||||
- [ ] Chat page sends message, streams reply
|
||||
- [ ] Sessions page lists `~/.praisonai/sessions/` data
|
||||
- [ ] Agents page CRUD (if agents registered)
|
||||
- [ ] Jobs page (if `modules` includes `jobs` and jobs API configured)
|
||||
- [ ] Explorer probes `/health`, `/ui-config.json`, `/api/dashboard/plugins`
|
||||
- [ ] Auth/API pages (if `modules` includes `auth` / `api`)
|
||||
|
||||
## Related
|
||||
|
||||
- [Agent UI host](agent-ui-host.md) — extension API, opt-in matrix
|
||||
- [Backend integration](backend-integration.md) — L1 backend injection
|
||||
- [Standalone vs integrated](standalone-vs-integrated.md) — SDK gaps and fallbacks
|
||||
- [PraisonAIUI repository](https://github.com/MervinPraison/PraisonAIUI)
|
||||
- [`integration.py`](../../src/praisonaiui/integration.py) — `AIUIGateway`
|
||||
- [`examples/python/praisonai-claw-board/`](../../examples/python/praisonai-claw-board/app.py) — board from jobs
|
||||
- [`examples/python/platform-board/`](../../examples/python/platform-board/app.py) — board from platform issues
|
||||
+54
-51
@@ -1,73 +1,76 @@
|
||||
# SEO
|
||||
|
||||
Built-in SEO features for better search engine visibility.
|
||||
Built-in SEO for documentation sites — sitemap, robots, llms.txt, per-page meta tags, and crawlable static HTML.
|
||||
|
||||
## Automatic Meta Tags
|
||||
## Configuration
|
||||
|
||||
PraisonAIUI generates meta tags from your configuration:
|
||||
Top-level `seo` block in `aiui.template.yaml`:
|
||||
|
||||
```yaml
|
||||
site:
|
||||
title: "My Documentation"
|
||||
description: "Comprehensive guides for my product"
|
||||
seo:
|
||||
siteUrl: "https://ui.praison.ai"
|
||||
titleTemplate: "%s | %s"
|
||||
defaultImage: "/icon.svg"
|
||||
robots:
|
||||
index: true
|
||||
follow: true
|
||||
twitter:
|
||||
handle: "@yourhandle"
|
||||
```
|
||||
|
||||
Generates:
|
||||
```html
|
||||
<title>My Documentation</title>
|
||||
<meta name="description" content="Comprehensive guides for my product">
|
||||
```
|
||||
`siteUrl` is required for absolute canonical URLs, sitemap, and social tags.
|
||||
|
||||
## Page-Level Overrides
|
||||
## Build outputs
|
||||
|
||||
Use frontmatter to override per-page:
|
||||
`aiui build` automatically generates:
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `sitemap.xml` | All doc URLs with `lastmod` dates |
|
||||
| `robots.txt` | Crawl rules + sitemap pointer |
|
||||
| `llms.txt` | AI crawler index ([llmstxt.org](https://llmstxt.org)) |
|
||||
| `llms-full.txt` | Full markdown export for AI tools |
|
||||
| `docs/**/*.html` | Per-route HTML with meta tags + crawl nav |
|
||||
| `.nojekyll` | GitHub Pages Jekyll bypass |
|
||||
|
||||
No extra flags are needed — SEO files are emitted whenever `content.docs` is configured.
|
||||
|
||||
## Page-level overrides
|
||||
|
||||
Use frontmatter on any markdown page:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "Custom Page Title"
|
||||
description: "Custom meta description for this page"
|
||||
description: "Custom meta description for search snippets"
|
||||
noindex: true
|
||||
---
|
||||
|
||||
# Page Content
|
||||
```
|
||||
|
||||
## SEO Configuration
|
||||
If `description` is omitted, the first paragraph is used automatically.
|
||||
|
||||
## Per-page HTML includes
|
||||
|
||||
Each route HTML file contains:
|
||||
|
||||
- `<link rel="canonical">` (absolute HTTPS)
|
||||
- `<meta name="description">`
|
||||
- Open Graph tags (`og:title`, `og:description`, `og:url`, `og:type`, `og:image`)
|
||||
- Twitter Card tags
|
||||
- JSON-LD `TechArticle` schema
|
||||
- Static `<nav>` with links to every doc page (for non-JS crawlers)
|
||||
- `<noscript>` pre-rendered page content
|
||||
|
||||
## Runtime (SPA navigation)
|
||||
|
||||
When users navigate via the sidebar, React updates title, canonical, OG, Twitter, robots, and JSON-LD to match the current page.
|
||||
|
||||
## Deploy
|
||||
|
||||
GitHub Pages deploy runs via the **Deploy Docs** workflow on every push to `main`. Submit your sitemap in [Google Search Console](https://search.google.com/search-console):
|
||||
|
||||
```yaml
|
||||
site:
|
||||
seo:
|
||||
titleTemplate: "%s | My Site"
|
||||
defaultImage: "/og-image.png"
|
||||
twitterHandle: "@myhandle"
|
||||
```
|
||||
|
||||
## Open Graph
|
||||
|
||||
Automatic Open Graph tags for social sharing:
|
||||
|
||||
```html
|
||||
<meta property="og:title" content="Page Title">
|
||||
<meta property="og:description" content="Page description">
|
||||
<meta property="og:image" content="/og-image.png">
|
||||
<meta property="og:type" content="website">
|
||||
```
|
||||
|
||||
## Sitemap
|
||||
|
||||
Generate a sitemap during build:
|
||||
|
||||
```bash
|
||||
aiui build --sitemap
|
||||
```
|
||||
|
||||
Creates `sitemap.xml` with all pages.
|
||||
|
||||
## Robots.txt
|
||||
|
||||
```yaml
|
||||
site:
|
||||
seo:
|
||||
robots:
|
||||
index: true
|
||||
follow: true
|
||||
https://your-domain.com/sitemap.xml
|
||||
```
|
||||
@@ -0,0 +1,53 @@
|
||||
# Standalone vs Integrated
|
||||
|
||||
This page summarises the expected install and runtime shape for PraisonAIUI in both modes.
|
||||
|
||||
## Install Matrix
|
||||
|
||||
| Mode | Package install | Runtime dependency | Behaviour |
|
||||
| --- | --- | --- | --- |
|
||||
| Standalone UI | `pip install praisonaiui` | Optional SDK packages absent | Uses built-in local managers and fallbacks |
|
||||
| Integrated (PraisonAI) | `pip install praisonaiui praisonai praisonaiagents` | SDK and wrapper available | Uses injected backends and SDK-aware managers |
|
||||
|
||||
## Backend injection (integrated mode)
|
||||
|
||||
When hosted via `praisonai.integration.host_app.configure_host()`, the wrapper calls `praisonaiui.backends.set_backend()` for:
|
||||
|
||||
| Backend key | Feature area |
|
||||
| --- | --- |
|
||||
| `hooks` | Hooks dashboard |
|
||||
| `workflows` | Workflow runs |
|
||||
| `usage_query` / `usage_sink` | Usage analytics |
|
||||
| `approvals_pending` / `approvals_policies` | HITL approvals |
|
||||
| `jobs_store` / `jobs_executor` | Async jobs (optional) |
|
||||
| `channel_bot` | Channel bots (optional) |
|
||||
| `tool_resolver` | Agent tool name resolution (optional) |
|
||||
|
||||
Detect integrated mode in code: `praisonaiui.backends.is_integrated_mode()`.
|
||||
|
||||
## Documented SDK gaps (local fallback)
|
||||
|
||||
These features work standalone with in-memory stores. Health responses include `sdk_gap: true`:
|
||||
|
||||
| Feature | Gap | Standalone behaviour |
|
||||
| --- | --- | --- |
|
||||
| **nodes** | No cluster API in praisonaiagents | In-memory node registry |
|
||||
| **auth** | No SDK auth module | In-memory API keys / sessions |
|
||||
| **channels** | Bot runtime via praisonai wrapper | In-memory channel registry; bots need `praisonai[bot]` |
|
||||
| **jobs** | Mock success only when standalone and agent missing | Integrated mode fails job instead of fake success |
|
||||
| **realtime** | WebRTC needs `OPENAI_API_KEY` | Mock stream standalone; degraded error when integrated without key |
|
||||
| **media / code** | SDK paths optional | `status: simulated` standalone; `status: degraded` integrated on failure |
|
||||
|
||||
View gaps in the dashboard sidebar footer (`GET /api/health` → `sdk_gaps` array).
|
||||
|
||||
## Practical Differences
|
||||
|
||||
- **Session/data handling**: standalone uses UI datastore fallback; integrated aligns with SDK-backed stores.
|
||||
- **Feature backends**: standalone keeps in-memory/mock behaviour; integrated routes through injected factories.
|
||||
- **Lifecycle hooks**: integrated mode can subscribe to SDK hooks (handoff, MCP lifecycle, tracing/eval stores).
|
||||
|
||||
## Recommendation
|
||||
|
||||
Use standalone mode for local UI-only workflows. Use integrated mode when you need shared runtime state with PraisonAI agents, tools, tracing, approvals, and schedules.
|
||||
|
||||
See also [backend-integration.md](backend-integration.md) and [praisonai-package-integration.md](praisonai-package-integration.md).
|
||||
@@ -0,0 +1,114 @@
|
||||
# Video Studio
|
||||
|
||||
The Video Studio integrates [PraisonAI Video](https://github.com/MervinPraison/praisonai-video) as an external Node render engine. PraisonAIUI provides the dashboard shell, project files, YAML editor, job UX, and HTTP proxying. The video engine handles parse, compile, lint, and MP4 export. **Agents live in PraisonAI (Chat + tools), not inside the renderer.**
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Install PraisonAI Video CLI (`praisonai-video`) or run from the monorepo.
|
||||
2. Start the engine sidecar (recommended):
|
||||
|
||||
```bash
|
||||
praisonai-video serve --port 3921
|
||||
```
|
||||
|
||||
3. Run the example app:
|
||||
|
||||
```bash
|
||||
aiui run examples/python/video-studio/app.py
|
||||
```
|
||||
|
||||
Open the **Video** tab, create a project, edit `scene.yaml`, and use Lint / Render.
|
||||
|
||||
For **Chat** to edit scenes, install `praisonaiagents` and set `OPENAI_API_KEY`. Optional: `VIDEO_STUDIO_PROJECT_ID` to target a specific project.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `VIDEO_ENGINE_URL` | `http://127.0.0.1:3921` | HTTP API base URL |
|
||||
| `VIDEO_ENGINE_TOKEN` | (none) | Optional Bearer token forwarded to the engine |
|
||||
| `PRAISONAI_PROJECTS_DIR` | `~/.praisonai/projects` | On-disk project roots |
|
||||
| `PRAISONAI_VIDEO_CLI` | (auto-detect) | Path to `praisonai-video` or `dist/cli.js` for subprocess fallback |
|
||||
| `VIDEO_STUDIO_PROJECT_ID` | (auto) | Active project for agent tools when not passed explicitly |
|
||||
| `PRAISONAI_MODEL` | `gpt-4o-mini` | LLM for Video Editor agent in the example app |
|
||||
|
||||
Python API:
|
||||
|
||||
```python
|
||||
from praisonaiui.video_config import set_video_engine
|
||||
|
||||
set_video_engine(url="http://127.0.0.1:3921", projects_dir="~/my-videos")
|
||||
```
|
||||
|
||||
## Projects
|
||||
|
||||
Each project is a directory containing:
|
||||
|
||||
- `scene.yaml` — scene definition
|
||||
- `scene.visual-test.yaml` — optional visual regression spec
|
||||
- `.praisonai/project.json` — metadata
|
||||
- `exports/` — rendered MP4 artefacts
|
||||
|
||||
## API routes
|
||||
|
||||
All routes are under `/api/video/`:
|
||||
|
||||
- `GET /health` — sidecar status
|
||||
- `POST /lint`, `POST /compile` — scene validation
|
||||
- `GET /preview-url`, `POST /preview/start` — preview iframe URL (CLI; dashboard preview hidden)
|
||||
- `POST /render` — async render job (`type: video_render`); optional `backend` (`playwright` | `remotion`) overrides `scene.yaml` `render.backend`
|
||||
- `GET /jobs/{id}` — job status and download URL
|
||||
- `GET|POST /projects` — list and create projects
|
||||
- `GET /projects/{id}/studio-refresh` — poll after agent tool updates (`refresh: true` once)
|
||||
|
||||
When the HTTP sidecar is unavailable, lint and render fall back to subprocess calls to `praisonai-video`.
|
||||
|
||||
## Dashboard plugin
|
||||
|
||||
The UI lives in `dashboard-plugins/video-studio/` (built-in) or `~/.praisonai/dashboard-plugins/video-studio/` for overrides. It uses `window.aiui.sdk.fetchJSON`.
|
||||
|
||||
**Frame-by-frame composition preview is hidden** in the dashboard (`SHOW_COMPOSITION_PREVIEW = false`) until serve preview + seek are reliable. Use **Render MP4** and the **Your video** player for output. Set the flag to `true` in `index.js` to re-enable the optional iframe scrubber.
|
||||
|
||||
After agent tool writes, the editor reloads via `video-studio:refresh` (custom event) or polling `studio-refresh`.
|
||||
|
||||
## Agent workflow (PraisonAI-first)
|
||||
|
||||
1. Open **Chat** with the Video Editor agent (example app registers tools when `praisonaiagents` is installed).
|
||||
2. Agent calls `video_get_scene` / `video_update_scene` / `video_lint_scene` / `video_render_project` (same outcomes as UI buttons).
|
||||
3. Video Studio tab picks up changes automatically.
|
||||
|
||||
Tools are defined in `praisonaiui/video_agent_tools.py`. For Cursor/Codex outside the UI, use the skill at `skills/praisonai-video/SKILL.md` in the PraisonAI Video repo.
|
||||
|
||||
### Optional render backends
|
||||
|
||||
Default is **Playwright** (no `render:` block in `scene.yaml`). Use Remotion only when required:
|
||||
|
||||
```yaml
|
||||
render:
|
||||
backend: remotion # needs Remotion licence + remotion-root export; lint will error on default YAML export
|
||||
```
|
||||
|
||||
`video_render_project(backend="playwright")` overrides YAML for one agent run. Hyperframes is deferred.
|
||||
|
||||
## CLI parity
|
||||
|
||||
The text box edits the same `scene.yaml` the CLI uses. Equivalent commands (from the video repo):
|
||||
|
||||
```bash
|
||||
praisonai-video project create "My video"
|
||||
praisonai-video project lint <project-id>
|
||||
praisonai-video project preview <project-id>
|
||||
praisonai-video project test <project-id>
|
||||
praisonai-video project render <project-id>
|
||||
praisonai-video project reset <project-id>
|
||||
```
|
||||
|
||||
Lint editor text without saving: `praisonai-video lint --stdin < scene.yaml`
|
||||
|
||||
See `docs/integrations/praisonaiui.md` in the PraisonAI Video repo for the full mapping table.
|
||||
|
||||
## Related
|
||||
|
||||
- [Agent UI host](agent-ui-host.md) — extension patterns (`registerView`, plugins, `@aiui.page`)
|
||||
- PraisonAI Video integration doc: `docs/integrations/praisonaiui.md` in the video repo
|
||||
- PraisonAI Video skill: `skills/praisonai-video/SKILL.md` — YAML-first invariants for agents and CI
|
||||
@@ -0,0 +1,35 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,102 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,107 @@
|
||||
# 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.
|
||||
@@ -276,10 +276,13 @@ aiui features list # List all registered features
|
||||
aiui features status # Feature health summary
|
||||
```
|
||||
|
||||
### `aiui health`
|
||||
### `aiui health-check`
|
||||
|
||||
Check server health. Can also be invoked as `aiui health` for convenience.
|
||||
|
||||
```bash
|
||||
aiui health [--detailed] # Check server health
|
||||
aiui health-check [--detailed] # Check server health
|
||||
aiui health [--detailed] # Alias for health-check
|
||||
```
|
||||
|
||||
### `aiui session-ext`
|
||||
|
||||
@@ -101,6 +101,6 @@ If something isn't working:
|
||||
|
||||
```bash
|
||||
aiui doctor # Check server health
|
||||
aiui health --detailed # Detailed health check
|
||||
aiui health-check --detailed # Detailed health check (alias: aiui health)
|
||||
aiui test all # Run all integration tests
|
||||
```
|
||||
@@ -0,0 +1,209 @@
|
||||
# Health Endpoints Guide
|
||||
|
||||
## Overview
|
||||
|
||||
PraisonAIUI provides multiple health endpoints optimized for different use cases:
|
||||
|
||||
- **`/health/live`** - Fast liveness check (<500ms)
|
||||
- **`/health/ready`** - Readiness check with optional deep checks
|
||||
- **`/health`** - Legacy endpoint (backward compatibility)
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Liveness Check: `/health/live`
|
||||
|
||||
Fast endpoint to verify the server is running. Returns immediately with minimal processing.
|
||||
|
||||
**Use for:**
|
||||
- Load balancer health probes
|
||||
- Kubernetes liveness probes
|
||||
- High-frequency monitoring
|
||||
|
||||
**Response time:** <500ms (typically <100ms)
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl http://localhost:8082/health/live
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"timestamp": "2024-01-20T10:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Readiness Check: `/health/ready`
|
||||
|
||||
Verifies the server is ready to handle requests. Performs parallel health checks on all features with timeout protection.
|
||||
|
||||
**Use for:**
|
||||
- Kubernetes readiness probes
|
||||
- Pre-deployment validation
|
||||
- Detailed diagnostics
|
||||
|
||||
**Query parameters:**
|
||||
- `?deep=false` - Skip feature checks for faster response
|
||||
|
||||
**Response time:**
|
||||
- With deep checks: <1s (parallel execution)
|
||||
- Without deep checks: <200ms
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
# Full readiness check
|
||||
curl http://localhost:8082/health/ready
|
||||
|
||||
# Basic readiness (no feature checks)
|
||||
curl http://localhost:8082/health/ready?deep=false
|
||||
```
|
||||
|
||||
**Response with deep checks:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"timestamp": "2024-01-20T10:30:00.000Z",
|
||||
"provider": {
|
||||
"name": "PraisonAIProvider",
|
||||
"status": "ok"
|
||||
},
|
||||
"features": {
|
||||
"agents": {"status": "ok", "feature": "agents"},
|
||||
"chat": {"status": "ok", "feature": "chat"},
|
||||
"memory": {"healthy": false, "detail": "timeout"}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API Health: `/api/health`
|
||||
|
||||
Cached version of readiness check for dashboard polling. 30-second TTL to reduce load.
|
||||
|
||||
**Use for:**
|
||||
- Dashboard health indicators
|
||||
- Frequent client polling
|
||||
- Status pages
|
||||
|
||||
**Response:** Same as `/health/ready` but cached for 30 seconds
|
||||
|
||||
## Deployment Configurations
|
||||
|
||||
### Kubernetes
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: praisonaiui
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: praisonaiui
|
||||
image: praisonaiui:latest
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health/live
|
||||
port: 8082
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 2
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health/ready?deep=false
|
||||
port: 8082
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
```
|
||||
|
||||
### AWS ALB/ELB
|
||||
|
||||
```terraform
|
||||
resource "aws_lb_target_group" "praisonaiui" {
|
||||
health_check {
|
||||
enabled = true
|
||||
path = "/health/live"
|
||||
interval = 30
|
||||
timeout = 5
|
||||
healthy_threshold = 2
|
||||
unhealthy_threshold = 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```yaml
|
||||
services:
|
||||
praisonaiui:
|
||||
image: praisonaiui:latest
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8082/health/live"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
```
|
||||
|
||||
### Nginx
|
||||
|
||||
```nginx
|
||||
upstream praisonaiui {
|
||||
server backend1:8082 max_fails=2 fail_timeout=5s;
|
||||
server backend2:8082 max_fails=2 fail_timeout=5s;
|
||||
|
||||
# Health check configuration
|
||||
check interval=5000 rise=2 fall=3 timeout=2000 type=http;
|
||||
check_http_send "GET /health/live HTTP/1.0\r\n\r\n";
|
||||
check_http_expect_alive http_2xx;
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Guide
|
||||
|
||||
If you're upgrading from a version using only `/health`:
|
||||
|
||||
1. **Update monitoring:** Change health checks to use `/health/live` for liveness
|
||||
2. **Update readiness:** Use `/health/ready?deep=false` for readiness probes
|
||||
3. **Update dashboards:** Point status indicators to `/api/health` (cached)
|
||||
4. **Backward compatibility:** The `/health` endpoint remains available but may be slow
|
||||
|
||||
## Performance Characteristics
|
||||
|
||||
| Endpoint | Cold Start | Warm (p50) | Warm (p95) | Use Case |
|
||||
|----------|------------|------------|------------|----------|
|
||||
| `/health/live` | <100ms | <20ms | <50ms | Liveness probes |
|
||||
| `/health/ready?deep=false` | <200ms | <50ms | <100ms | Basic readiness |
|
||||
| `/health/ready` | <1s | <500ms | <800ms | Full diagnostics |
|
||||
| `/api/health` (cached) | <1s* | <20ms | <50ms | Dashboard polling |
|
||||
|
||||
*First request only, then cached for 30s
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Slow `/health` responses
|
||||
|
||||
If you're seeing slow responses on the legacy `/health` endpoint:
|
||||
- Switch to `/health/live` for fast checks
|
||||
- Use `/health/ready?deep=false` if you don't need feature health
|
||||
- Enable caching with `/api/health` for frequent polling
|
||||
|
||||
### Feature timeouts
|
||||
|
||||
Features that timeout in `/health/ready` will show:
|
||||
```json
|
||||
{
|
||||
"feature_name": {
|
||||
"healthy": false,
|
||||
"detail": "timeout"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This indicates the feature took longer than 500ms to respond. The server is still operational, but that specific feature may be experiencing issues.
|
||||
|
||||
### High latency under load
|
||||
|
||||
Under high load, use `/health/live` exclusively for monitoring. It has minimal overhead and won't impact performance.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Application Design Protocol (ADP)
|
||||
|
||||
Declarative UI design standard for describing application layout, navigation, components, and theming from YAML or JSON manifests.
|
||||
|
||||
**Status:** v1alpha1 research draft — see [implementation-status.md](./implementation-status.md) for what is implemented today.
|
||||
|
||||
## Documents
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [Research report](../application-design-protocol-report.md) | 46 standards compared; YAML vs JSON debate; design principles |
|
||||
| [spec.md](./spec.md) | Normative specification (draft) |
|
||||
| [validation-rules.md](./validation-rules.md) | Cross-reference rules R1–R30 |
|
||||
| [aiui-mapping.md](./aiui-mapping.md) | PraisonAIUI field mapping |
|
||||
| [implementation-status.md](./implementation-status.md) | Feature implementation tracker |
|
||||
|
||||
## Schemas
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| [schema/application.schema.json](./schema/application.schema.json) | Root Application kind |
|
||||
| [schema/component-catalog.schema.json](./schema/component-catalog.schema.json) | Catalog meta-schema |
|
||||
| [schema/theme.schema.json](./schema/theme.schema.json) | Theme preset or DTCG tokens |
|
||||
| [catalogs/default.catalog.json](./catalogs/default.catalog.json) | Default component and layout catalog |
|
||||
|
||||
## Examples
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| [examples/minimal.application.yaml](./examples/minimal.application.yaml) | Smallest valid app |
|
||||
| [examples/docs-site.application.yaml](./examples/docs-site.application.yaml) | Full docs site |
|
||||
| [examples/landing-page.application.yaml](./examples/landing-page.application.yaml) | Zone-based layout |
|
||||
|
||||
## Validate locally
|
||||
|
||||
```bash
|
||||
pip install jsonschema pyyaml
|
||||
pytest tests/unit/test_adp_schemas.py -v
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```yaml
|
||||
schemaVersion: 1
|
||||
apiVersion: adp.io/v1alpha1
|
||||
kind: Application
|
||||
|
||||
metadata:
|
||||
name: my-app
|
||||
version: "1.0.0"
|
||||
|
||||
spec:
|
||||
site:
|
||||
title: "My App"
|
||||
theme:
|
||||
preset: zinc
|
||||
darkMode: true
|
||||
templates:
|
||||
docs:
|
||||
layout: TwoColumnLayout
|
||||
slots:
|
||||
main: { type: DocContent }
|
||||
routes:
|
||||
- match: "/docs/**"
|
||||
template: docs
|
||||
content:
|
||||
docs:
|
||||
dir: "./docs"
|
||||
```
|
||||
@@ -0,0 +1,138 @@
|
||||
# PraisonAIUI → ADP Field Mapping
|
||||
|
||||
Field-by-field mapping from current `aiui.template.yaml` (`Config` model in `src/praisonaiui/schema/models.py`) to ADP `Application` spec.
|
||||
|
||||
**Compatibility:** Flat `aiui.template.yaml` files work with the PraisonAIUI compiler today. ADP envelope format is **planned** — see [implementation-status.md](./implementation-status.md).
|
||||
|
||||
---
|
||||
|
||||
## Envelope (new in ADP)
|
||||
|
||||
| ADP field | PraisonAIUI today | Status | Change |
|
||||
|-----------|-------------------|--------|--------|
|
||||
| `schemaVersion: 1` | `schemaVersion: 1` | Implemented | Compatible |
|
||||
| `apiVersion: adp.io/v1alpha1` | Not present | Planned | Add when envelope parser lands |
|
||||
| `kind: Application` | Not present | Planned | Add when envelope parser lands |
|
||||
| `metadata.name` | Not present | Planned | Derive from `site.title` slug |
|
||||
| `metadata.version` | Not present | Planned | Recommend SemVer |
|
||||
|
||||
## Top-level → spec wrapper
|
||||
|
||||
| PraisonAIUI (flat) | ADP (envelope) | Status | Change |
|
||||
|--------------------|----------------|--------|--------|
|
||||
| `site:` | `spec.site:` | Planned | Wrap under `spec` |
|
||||
| `site.theme` | `spec.theme:` | Planned | Promote theme to spec level |
|
||||
| `navigation:` | `spec.navigation:` | Partial | Tabs compiled; not rendered |
|
||||
| `navbar:` | `spec.navbar:` | Partial | Compiled; not rendered |
|
||||
| `footer:` | `spec.footer:` | Partial | Compiled; not rendered |
|
||||
| `search:` | `spec.search:` | Partial | Compiled; not rendered |
|
||||
| `content:` | `spec.content:` | Implemented | Same |
|
||||
| `components:` | `spec.components:` | Implemented | Same |
|
||||
| `templates:` | `spec.templates:` | Implemented | Same |
|
||||
| `routes:` | `spec.routes:` | Partial | Compiled; routing not honoured |
|
||||
| `dependencies:` | `spec.dependencies:` | Implemented | Same |
|
||||
| `logo:` (top-level) | Future ADP field | Implemented | Top-level on `Config`, not under `site` |
|
||||
| `style:`, `chat:`, `auth:`, `dashboard:` | Not in ADP v1alpha1 | N/A | Separate kinds in future |
|
||||
|
||||
## Navigation fields
|
||||
|
||||
| Field | PraisonAIUI | ADP | Status | Notes |
|
||||
|-------|-------------|-----|--------|-------|
|
||||
| `navigation.tabs[]` | Mintlify-style tabs | `spec.navigation.tabs` | Partial | Compiled, not rendered |
|
||||
| `content.docs.nav.mode` | `NavConfig.mode` | Same | Gap | Defined but unused by compiler |
|
||||
| Top-level `navigation.mode` | Not present | Removed from ADP | N/A | Use `content.docs.nav.mode` instead |
|
||||
|
||||
## Theme fields
|
||||
|
||||
| Field | PraisonAIUI | ADP | Status |
|
||||
|-------|-------------|-----|--------|
|
||||
| `site.theme.preset` | Implemented | `spec.theme.preset` | Planned (envelope) |
|
||||
| `site.theme.radius` | Implemented | Same | Same |
|
||||
| `site.theme.darkMode` | Implemented | Same | Same |
|
||||
| `theme.tokens` | Not present | DTCG file ref | Planned |
|
||||
|
||||
## Component fields
|
||||
|
||||
| Field | PraisonAIUI | ADP | Status |
|
||||
|-------|-------------|-----|--------|
|
||||
| `components.{id}.type` | String literal | Catalog allowlist | Planned (R6) |
|
||||
| `components.{id}.props` | `dict[str, Any]` | Catalog schema | Planned (R7) |
|
||||
|
||||
## Template fields
|
||||
|
||||
| Field | PraisonAIUI | ADP | Status |
|
||||
|-------|-------------|-----|--------|
|
||||
| `templates.{id}.layout` | String | Catalog enum | Same values |
|
||||
| `templates.{id}.slots` | `{ ref \| type }` | Same | Same |
|
||||
| `templates.{id}.zones` | Widget arrays | Same | Same |
|
||||
| Slots + zones on same template | Allowed | R13: one or the other | Stricter in ADP |
|
||||
|
||||
## Route fields
|
||||
|
||||
| Field | PraisonAIUI | ADP | Status |
|
||||
|-------|-------------|-----|--------|
|
||||
| `routes[].match` | Glob string | Same | Same |
|
||||
| `routes[].template` | Template key | Same | Same |
|
||||
| Runtime route resolution | Not implemented | R28 required | Gap |
|
||||
|
||||
## Migration example
|
||||
|
||||
### Today (PraisonAIUI)
|
||||
|
||||
```yaml
|
||||
schemaVersion: 1
|
||||
|
||||
site:
|
||||
title: "My Docs"
|
||||
theme:
|
||||
preset: zinc
|
||||
|
||||
templates:
|
||||
docs:
|
||||
layout: TwoColumnLayout
|
||||
slots:
|
||||
main: { type: DocContent }
|
||||
|
||||
routes:
|
||||
- match: "/docs/**"
|
||||
template: docs
|
||||
```
|
||||
|
||||
### Target (ADP)
|
||||
|
||||
```yaml
|
||||
schemaVersion: 1
|
||||
apiVersion: adp.io/v1alpha1
|
||||
kind: Application
|
||||
|
||||
metadata:
|
||||
name: my-docs
|
||||
version: "1.0.0"
|
||||
|
||||
spec:
|
||||
site:
|
||||
title: "My Docs"
|
||||
theme:
|
||||
preset: zinc
|
||||
templates:
|
||||
docs:
|
||||
layout: TwoColumnLayout
|
||||
slots:
|
||||
main: { type: DocContent }
|
||||
routes:
|
||||
- match: "/docs/**"
|
||||
template: docs
|
||||
content:
|
||||
docs:
|
||||
dir: "./docs"
|
||||
```
|
||||
|
||||
## Breaking vs compatible changes
|
||||
|
||||
| Change | Breaking? | Mitigation |
|
||||
|--------|-----------|------------|
|
||||
| Add envelope fields | No | Envelope parser normalises to flat `Config` |
|
||||
| Catalog validation | Soft break | Warn first, error in strict mode |
|
||||
| Route runtime resolution | No (fix) | Frontend change |
|
||||
| R13 slots/zones exclusivity | Soft break | Warn on violation |
|
||||
| Remove chat/dashboard from ADP | No | Separate kinds in future |
|
||||
@@ -0,0 +1,128 @@
|
||||
{
|
||||
"name": "adp-default-catalog",
|
||||
"version": "1.0.0",
|
||||
"description": "Default ADP component and layout catalog for PraisonAIUI",
|
||||
"components": {
|
||||
"Header": {
|
||||
"description": "Site header with logo and navigation",
|
||||
"props": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"logoText": { "type": "string" },
|
||||
"logoHref": { "type": "string", "default": "/" }
|
||||
},
|
||||
"required": ["logoText"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"aria": { "role": "banner" },
|
||||
"slots": ["header"]
|
||||
},
|
||||
"Footer": {
|
||||
"description": "Site footer",
|
||||
"props": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"aria": { "role": "contentinfo" },
|
||||
"slots": ["footer"]
|
||||
},
|
||||
"DocsSidebar": {
|
||||
"description": "Documentation sidebar navigation",
|
||||
"props": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source": { "type": "string", "enum": ["docs-nav", "manual"], "default": "docs-nav" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"aria": { "role": "navigation" },
|
||||
"slots": ["left", "leftSidebar"]
|
||||
},
|
||||
"DocContent": {
|
||||
"description": "Main documentation content area",
|
||||
"props": { "type": "object", "additionalProperties": false },
|
||||
"aria": { "role": "main" },
|
||||
"slots": ["main"]
|
||||
},
|
||||
"Toc": {
|
||||
"description": "Table of contents for current page",
|
||||
"props": { "type": "object", "additionalProperties": false },
|
||||
"aria": { "role": "navigation" },
|
||||
"slots": ["right", "rightSidebar"]
|
||||
},
|
||||
"HeroBanner": {
|
||||
"description": "Hero banner with title and CTA",
|
||||
"props": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": { "type": "string" },
|
||||
"subtitle": { "type": "string" },
|
||||
"ctaLabel": { "type": "string" },
|
||||
"ctaHref": { "type": "string" }
|
||||
},
|
||||
"required": ["title"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"slots": ["hero"]
|
||||
},
|
||||
"SocialLinks": {
|
||||
"description": "Social media link row",
|
||||
"props": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"links": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"platform": { "type": "string" },
|
||||
"href": { "type": "string" }
|
||||
},
|
||||
"required": ["platform", "href"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"slots": ["footer"]
|
||||
},
|
||||
"Copyright": {
|
||||
"description": "Copyright notice",
|
||||
"props": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": { "type": "string" }
|
||||
},
|
||||
"required": ["text"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"slots": ["footer"]
|
||||
}
|
||||
},
|
||||
"layouts": {
|
||||
"ThreeColumnLayout": {
|
||||
"description": "Header + left sidebar + main + right TOC + footer",
|
||||
"slots": ["header", "left", "main", "right", "footer"]
|
||||
},
|
||||
"TwoColumnLayout": {
|
||||
"description": "Sidebar + main content",
|
||||
"slots": ["left", "main"]
|
||||
},
|
||||
"CenteredLayout": {
|
||||
"description": "Centred single column",
|
||||
"slots": ["main"]
|
||||
},
|
||||
"FullWidthLayout": {
|
||||
"description": "Full-width content",
|
||||
"slots": ["header", "main", "footer"]
|
||||
},
|
||||
"FlexibleLayout": {
|
||||
"description": "WordPress-style zone grid",
|
||||
"zones": ["header", "topNav", "hero", "leftSidebar", "main", "rightSidebar", "bottomNav", "footer"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
schemaVersion: 1
|
||||
apiVersion: adp.io/v1alpha1
|
||||
kind: Application
|
||||
|
||||
metadata:
|
||||
name: praisonaiui-docs
|
||||
version: "1.0.0"
|
||||
labels:
|
||||
generator: praisonaiui
|
||||
|
||||
spec:
|
||||
site:
|
||||
title: "PraisonAIUI Docs"
|
||||
description: "Documentation for PraisonAIUI - YAML-driven website generator"
|
||||
ui: shadcn
|
||||
|
||||
theme:
|
||||
preset: zinc
|
||||
radius: md
|
||||
darkMode: true
|
||||
|
||||
navigation:
|
||||
tabs:
|
||||
- tab: "Documentation"
|
||||
groups:
|
||||
- group: "Getting Started"
|
||||
icon: rocket
|
||||
prefix: getting-started
|
||||
pages:
|
||||
- index
|
||||
- getting-started/installation
|
||||
- getting-started/quickstart
|
||||
- group: "Core Concepts"
|
||||
icon: lightbulb
|
||||
prefix: concepts
|
||||
pages:
|
||||
- concepts/architecture
|
||||
- concepts/configuration
|
||||
- concepts/templates
|
||||
- tab: "API Reference"
|
||||
groups:
|
||||
- group: "API Reference"
|
||||
icon: code
|
||||
prefix: api
|
||||
pages:
|
||||
- api/cli
|
||||
- api/python
|
||||
|
||||
navbar:
|
||||
primary:
|
||||
type: github
|
||||
href: "https://github.com/MervinPraison/PraisonAIUI"
|
||||
links:
|
||||
- label: "Submit Issues"
|
||||
href: "https://github.com/MervinPraison/PraisonAIUI/issues"
|
||||
|
||||
footer:
|
||||
socials:
|
||||
github: "https://github.com/MervinPraison/PraisonAI"
|
||||
links:
|
||||
- header: "Resources"
|
||||
items:
|
||||
- label: "GitHub"
|
||||
href: "https://github.com/MervinPraison/PraisonAI"
|
||||
|
||||
search:
|
||||
enabled: true
|
||||
provider: fusejs
|
||||
|
||||
content:
|
||||
docs:
|
||||
dir: "./docs"
|
||||
include:
|
||||
- "**/*.md"
|
||||
- "**/*.mdx"
|
||||
nav:
|
||||
mode: manual
|
||||
|
||||
components:
|
||||
header_main:
|
||||
type: Header
|
||||
props:
|
||||
logoText: "PraisonAIUI"
|
||||
footer_main:
|
||||
type: Footer
|
||||
props:
|
||||
text: "© 2026 PraisonAI"
|
||||
sidebar_docs:
|
||||
type: DocsSidebar
|
||||
props:
|
||||
source: docs-nav
|
||||
|
||||
templates:
|
||||
docs:
|
||||
layout: ThreeColumnLayout
|
||||
slots:
|
||||
header: { ref: header_main }
|
||||
left: { ref: sidebar_docs }
|
||||
main: { type: DocContent }
|
||||
footer: { ref: footer_main }
|
||||
|
||||
routes:
|
||||
- match: "/docs/**"
|
||||
template: docs
|
||||
@@ -0,0 +1,58 @@
|
||||
schemaVersion: 1
|
||||
apiVersion: adp.io/v1alpha1
|
||||
kind: Application
|
||||
|
||||
metadata:
|
||||
name: colorful-landing
|
||||
version: "1.0.0"
|
||||
|
||||
spec:
|
||||
site:
|
||||
title: "Colorful Docs"
|
||||
description: "Zone-based landing page with hero and footer widgets"
|
||||
ui: shadcn
|
||||
|
||||
theme:
|
||||
preset: rose
|
||||
radius: xl
|
||||
darkMode: true
|
||||
|
||||
content:
|
||||
docs:
|
||||
dir: "./docs"
|
||||
nav:
|
||||
mode: auto
|
||||
collapsible: true
|
||||
|
||||
templates:
|
||||
docs:
|
||||
layout: FlexibleLayout
|
||||
zones:
|
||||
hero:
|
||||
- type: HeroBanner
|
||||
props:
|
||||
title: "Colorful Documentation"
|
||||
subtitle: "Vibrant, playful, and beautifully designed"
|
||||
ctaLabel: "Get Started"
|
||||
ctaHref: "/docs/getting-started/quickstart"
|
||||
rightSidebar:
|
||||
- type: Toc
|
||||
footer:
|
||||
- type: SocialLinks
|
||||
props:
|
||||
links:
|
||||
- platform: Twitter
|
||||
href: "https://twitter.com"
|
||||
- platform: GitHub
|
||||
href: "https://github.com"
|
||||
- type: Copyright
|
||||
props:
|
||||
text: "Made with love by Colorful Team"
|
||||
|
||||
routes:
|
||||
- match: "/docs/**"
|
||||
template: docs
|
||||
|
||||
a11y:
|
||||
skipToContent: true
|
||||
reduceMotion: false
|
||||
@@ -0,0 +1,32 @@
|
||||
schemaVersion: 1
|
||||
apiVersion: adp.io/v1alpha1
|
||||
kind: Application
|
||||
|
||||
metadata:
|
||||
name: minimal-docs
|
||||
version: "1.0.0"
|
||||
|
||||
spec:
|
||||
site:
|
||||
title: "Minimal Docs"
|
||||
description: "Smallest valid ADP Application manifest"
|
||||
ui: shadcn
|
||||
|
||||
theme:
|
||||
preset: zinc
|
||||
radius: md
|
||||
darkMode: true
|
||||
|
||||
content:
|
||||
docs:
|
||||
dir: "./docs"
|
||||
|
||||
templates:
|
||||
docs:
|
||||
layout: TwoColumnLayout
|
||||
slots:
|
||||
main: { type: DocContent }
|
||||
|
||||
routes:
|
||||
- match: "/docs/**"
|
||||
template: docs
|
||||
@@ -0,0 +1,36 @@
|
||||
# ADP Implementation Status
|
||||
|
||||
Honest status of ADP v1alpha1 features against the PraisonAIUI reference implementation.
|
||||
|
||||
**Last updated:** August 2026
|
||||
|
||||
| Feature | Spec reference | Status | Notes |
|
||||
|---------|----------------|--------|-------|
|
||||
| JSON Schema validation in CLI | spec §6 | Planned | Schemas published; no `aiui validate --adp` yet |
|
||||
| ADP envelope parsing (`apiVersion/kind/spec`) | spec §3 | Planned | Flat `aiui.template.yaml` works today |
|
||||
| Component catalog validation (R6–R7) | spec §5 | Planned | Catalog split to `catalogs/default.catalog.json` |
|
||||
| `$yaml:` / `$json:` file includes | spec §7 | Planned | Not implemented in compiler |
|
||||
| Strict mode unknown-field rejection (R4) | validation-rules R4 | Planned | Pydantic allows extra fields on some models |
|
||||
| Route-driven template resolution (R28) | validation-rules R28 | Gap | Frontend hardcodes `templates.docs` |
|
||||
| Sidebar nav mode (R29) | validation-rules R29 | Gap | Filesystem-only sidebar today |
|
||||
| Catalog ARIA roles at render (R30) | validation-rules R30 | Gap | No catalog-driven a11y in renderer |
|
||||
| `navigation.tabs` rendering | spec §4.3 | Gap | Compiled to manifest but not consumed by React |
|
||||
| DTCG token file validation (R26) | validation-rules R26 | Planned | Preset shorthand works via Tailwind |
|
||||
| Published `$schema` URLs | README | Partial | Local schemas only; `https://adp.io/` is placeholder |
|
||||
|
||||
## What works today
|
||||
|
||||
- Flat `aiui.template.yaml` → Pydantic `Config` → compiler → JSON manifests → React SPA
|
||||
- Components, templates, routes, theme presets, content scanning
|
||||
- ADP example manifests validate against `application.schema.json` (standalone)
|
||||
- Unit tests in `tests/unit/test_adp_schemas.py`
|
||||
|
||||
## Migration path
|
||||
|
||||
1. Add envelope normalisation layer before `Config.model_validate`
|
||||
2. Wire `route-manifest.json` into frontend routing (R28)
|
||||
3. Honour `navigation.tabs` and `content.docs.nav.mode` (R29)
|
||||
4. Add `aiui validate --schema adp` using published JSON Schema
|
||||
5. Apply catalog ARIA hints at render time (R30)
|
||||
|
||||
See [aiui-mapping.md](./aiui-mapping.md) for field-level compatibility.
|
||||
@@ -0,0 +1,449 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "application.schema.json",
|
||||
"title": "ADP Application",
|
||||
"description": "Application Design Protocol — Application kind manifest",
|
||||
"type": "object",
|
||||
"required": ["schemaVersion", "apiVersion", "kind", "metadata", "spec"],
|
||||
"additionalProperties": false,
|
||||
"patternProperties": {
|
||||
"^x-adp-": {}
|
||||
},
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"type": "integer",
|
||||
"const": 1,
|
||||
"description": "ADP envelope version"
|
||||
},
|
||||
"apiVersion": {
|
||||
"type": "string",
|
||||
"const": "adp.io/v1alpha1"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"const": "Application"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"required": ["name"],
|
||||
"additionalProperties": false,
|
||||
"patternProperties": {
|
||||
"^x-adp-": {}
|
||||
},
|
||||
"properties": {
|
||||
"name": { "type": "string", "minLength": 1 },
|
||||
"version": { "type": "string" },
|
||||
"labels": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" }
|
||||
},
|
||||
"annotations": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"type": "object",
|
||||
"required": ["site", "templates", "routes"],
|
||||
"additionalProperties": false,
|
||||
"patternProperties": {
|
||||
"^x-adp-": {}
|
||||
},
|
||||
"properties": {
|
||||
"site": { "$ref": "#/$defs/site" },
|
||||
"theme": { "$ref": "#/$defs/theme" },
|
||||
"navigation": { "$ref": "#/$defs/navigation" },
|
||||
"navbar": { "$ref": "#/$defs/navbar" },
|
||||
"footer": { "$ref": "#/$defs/footer" },
|
||||
"search": { "$ref": "#/$defs/search" },
|
||||
"components": { "$ref": "#/$defs/components" },
|
||||
"templates": { "$ref": "#/$defs/templates" },
|
||||
"routes": { "$ref": "#/$defs/routes" },
|
||||
"content": { "$ref": "#/$defs/content" },
|
||||
"dependencies": { "$ref": "#/$defs/dependencies" },
|
||||
"seo": { "$ref": "#/$defs/seo" },
|
||||
"i18n": { "$ref": "#/$defs/i18n" },
|
||||
"a11y": { "$ref": "#/$defs/a11y" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"site": {
|
||||
"type": "object",
|
||||
"required": ["title"],
|
||||
"additionalProperties": false,
|
||||
"patternProperties": {
|
||||
"^x-adp-": {}
|
||||
},
|
||||
"properties": {
|
||||
"title": { "type": "string" },
|
||||
"description": { "type": "string" },
|
||||
"ui": {
|
||||
"type": "string",
|
||||
"enum": ["shadcn", "mui", "chakra"],
|
||||
"default": "shadcn"
|
||||
},
|
||||
"routeBaseDocs": { "type": "string", "default": "/docs" },
|
||||
"customCss": { "type": "string" },
|
||||
"plugins": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"oneOf": [
|
||||
{
|
||||
"title": "Preset theme",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"preset": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"zinc", "slate", "stone", "gray", "neutral",
|
||||
"red", "orange", "amber", "yellow", "lime", "green",
|
||||
"emerald", "teal", "cyan", "sky", "blue", "indigo",
|
||||
"violet", "purple", "fuchsia", "pink", "rose"
|
||||
]
|
||||
},
|
||||
"radius": {
|
||||
"type": "string",
|
||||
"enum": ["none", "sm", "md", "lg", "xl"]
|
||||
},
|
||||
"darkMode": { "type": "boolean" }
|
||||
},
|
||||
"required": ["preset"]
|
||||
},
|
||||
{
|
||||
"title": "DTCG token reference",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"tokens": {
|
||||
"type": "string",
|
||||
"description": "Path or URL to DTCG 2025.10 compliant tokens.json"
|
||||
},
|
||||
"darkMode": { "type": "boolean" }
|
||||
},
|
||||
"required": ["tokens"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"slotRef": {
|
||||
"oneOf": [
|
||||
{ "type": "null" },
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["ref"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"ref": { "type": "string" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["type"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": { "type": "string" },
|
||||
"props": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"widget": {
|
||||
"type": "object",
|
||||
"required": ["type"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": { "type": "string" },
|
||||
"props": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"required": ["type"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": { "type": "string" },
|
||||
"props": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"template": {
|
||||
"type": "object",
|
||||
"required": ["layout"],
|
||||
"allOf": [
|
||||
{
|
||||
"oneOf": [
|
||||
{ "required": ["slots"] },
|
||||
{ "required": ["zones"] }
|
||||
]
|
||||
}
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"layout": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ThreeColumnLayout",
|
||||
"TwoColumnLayout",
|
||||
"CenteredLayout",
|
||||
"FullWidthLayout",
|
||||
"FlexibleLayout"
|
||||
]
|
||||
},
|
||||
"slots": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "$ref": "#/$defs/slotRef" }
|
||||
},
|
||||
"zones": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/widget" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"templates": {
|
||||
"type": "object",
|
||||
"minProperties": 1,
|
||||
"additionalProperties": { "$ref": "#/$defs/template" }
|
||||
},
|
||||
"route": {
|
||||
"type": "object",
|
||||
"required": ["match", "template"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"match": { "type": "string" },
|
||||
"template": { "type": "string" },
|
||||
"slots": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "$ref": "#/$defs/slotRef" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"routes": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/$defs/route" }
|
||||
},
|
||||
"navGroup": {
|
||||
"type": "object",
|
||||
"required": ["group"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"group": { "type": "string" },
|
||||
"icon": { "type": "string" },
|
||||
"prefix": { "type": "string" },
|
||||
"pages": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"navTab": {
|
||||
"type": "object",
|
||||
"required": ["tab"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"tab": { "type": "string" },
|
||||
"url": { "type": "string" },
|
||||
"groups": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/navGroup" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"navigation": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"tabs": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/navTab" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"primary": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": { "type": "string", "enum": ["button", "github"] },
|
||||
"label": { "type": "string" },
|
||||
"href": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["label", "href"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"label": { "type": "string" },
|
||||
"href": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"socials": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" }
|
||||
},
|
||||
"links": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["header"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"header": { "type": "string" },
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["label", "href"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"label": { "type": "string" },
|
||||
"href": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"enum": ["fusejs", "lunrjs", "pagefind"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"shadcn": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"contentSource": {
|
||||
"type": "object",
|
||||
"required": ["dir"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"dir": { "type": "string" },
|
||||
"include": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"exclude": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"indexFiles": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"nav": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"mode": { "type": "string", "enum": ["auto", "manual"] },
|
||||
"sort": { "type": "string", "enum": ["filesystem", "alpha", "date"] },
|
||||
"collapsible": { "type": "boolean" },
|
||||
"maxDepth": { "type": "integer", "minimum": 1, "maximum": 10 }
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": { "mode": { "const": "manual" } },
|
||||
"required": ["mode"]
|
||||
},
|
||||
"then": {
|
||||
"description": "Manual sidebar mode requires navigation.tabs at spec level"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"docs": { "$ref": "#/$defs/contentSource" },
|
||||
"blog": { "$ref": "#/$defs/contentSource" }
|
||||
}
|
||||
},
|
||||
"seo": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"titleTemplate": { "type": "string" },
|
||||
"defaultImage": { "type": "string" },
|
||||
"twitter": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"defaultLocale": { "type": "string" },
|
||||
"locales": { "type": "array", "items": { "type": "string" } },
|
||||
"rtlLocales": { "type": "array", "items": { "type": "string" } },
|
||||
"fallbackLocale": { "type": "string" },
|
||||
"translationsDir": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"a11y": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"skipToContent": { "type": "boolean" },
|
||||
"focusVisible": { "type": "boolean" },
|
||||
"reduceMotion": { "type": "boolean" },
|
||||
"ariaLabels": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "component-catalog.schema.json",
|
||||
"title": "ADP Component Catalog",
|
||||
"description": "Allowlisted component types for ADP Application manifests",
|
||||
"type": "object",
|
||||
"required": ["name", "version", "components"],
|
||||
"additionalProperties": false,
|
||||
"patternProperties": {
|
||||
"^x-adp-": {}
|
||||
},
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"version": { "type": "string" },
|
||||
"description": { "type": "string" },
|
||||
"components": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "$ref": "#/$defs/componentType" }
|
||||
},
|
||||
"layouts": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "$ref": "#/$defs/layoutType" }
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"componentType": {
|
||||
"type": "object",
|
||||
"required": ["description", "props"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"description": { "type": "string" },
|
||||
"props": { "type": "object" },
|
||||
"aria": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"role": { "type": "string" },
|
||||
"label": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"slots": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"deprecated": { "type": "boolean" },
|
||||
"x-sunset": { "type": "string", "format": "date" }
|
||||
}
|
||||
},
|
||||
"layoutType": {
|
||||
"oneOf": [
|
||||
{
|
||||
"title": "Slot-based layout",
|
||||
"type": "object",
|
||||
"required": ["description", "slots"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"description": { "type": "string" },
|
||||
"slots": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"minItems": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Zone-based layout",
|
||||
"type": "object",
|
||||
"required": ["description", "zones"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"description": { "type": "string" },
|
||||
"zones": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"minItems": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "theme.schema.json",
|
||||
"title": "ADP Theme",
|
||||
"description": "Theme configuration — preset shorthand or DTCG 2025.10 token reference",
|
||||
"oneOf": [
|
||||
{
|
||||
"title": "Preset theme",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"preset": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"zinc", "slate", "stone", "gray", "neutral",
|
||||
"red", "orange", "amber", "yellow", "lime", "green",
|
||||
"emerald", "teal", "cyan", "sky", "blue", "indigo",
|
||||
"violet", "purple", "fuchsia", "pink", "rose"
|
||||
]
|
||||
},
|
||||
"radius": {
|
||||
"type": "string",
|
||||
"enum": ["none", "sm", "md", "lg", "xl"],
|
||||
"default": "md"
|
||||
},
|
||||
"darkMode": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"required": ["preset"]
|
||||
},
|
||||
{
|
||||
"title": "DTCG token reference",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"tokens": {
|
||||
"type": "string",
|
||||
"description": "Path or URL to DTCG 2025.10 compliant tokens.json"
|
||||
},
|
||||
"darkMode": { "type": "boolean" }
|
||||
},
|
||||
"required": ["tokens"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
# Application Design Protocol (ADP) — Normative Specification
|
||||
|
||||
**Version:** v1alpha1 (draft)
|
||||
**Status:** Draft — not yet normative
|
||||
**Date:** August 2026
|
||||
|
||||
This document uses [RFC 2119](https://datatracker.ietf.org/doc/html/rfc2119) keywords: MUST, MUST NOT, SHOULD, MAY.
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope
|
||||
|
||||
ADP defines a declarative format for describing **application UI design**: site metadata, theming, navigation, reusable components, page templates, and URL routing. It does NOT define:
|
||||
|
||||
- Server-side APIs or data models
|
||||
- Agent orchestration or workflow graphs
|
||||
- Infrastructure or deployment configuration
|
||||
- Executable code or templating logic in manifests
|
||||
|
||||
Implementations MUST treat ADP manifests as **data**, not code.
|
||||
|
||||
---
|
||||
|
||||
## 2. Terminology
|
||||
|
||||
| Term | Definition |
|
||||
|------|------------|
|
||||
| **Manifest** | A YAML or JSON document conforming to this spec |
|
||||
| **Application** | The root `kind: Application` manifest describing a complete UI |
|
||||
| **Component** | A named, reusable UI block with a `type` and `props` |
|
||||
| **Template** | A layout definition assigning components to slots or zones |
|
||||
| **Slot** | A named region within a layout (e.g. `header`, `main`) |
|
||||
| **Zone** | An ordered list of widgets in a slot (WordPress-style) |
|
||||
| **Route** | A URL pattern mapping to a template |
|
||||
| **Catalog** | Published JSON Schema defining allowed component types |
|
||||
| **Compiler** | Tool that validates and emits runtime JSON manifests |
|
||||
| **Renderer** | Runtime that consumes compiled manifests and renders UI |
|
||||
|
||||
---
|
||||
|
||||
## 3. Document envelope
|
||||
|
||||
Every ADP manifest MUST include:
|
||||
|
||||
```yaml
|
||||
schemaVersion: 1 # Integer; ADP envelope version
|
||||
apiVersion: adp.io/v1alpha1
|
||||
kind: Application # Application | Theme | ComponentCatalog (future)
|
||||
|
||||
metadata:
|
||||
name: string # Required; unique identifier
|
||||
version: string # SHOULD use SemVer
|
||||
labels: object # MAY contain key-value pairs
|
||||
annotations: object # MAY contain tooling metadata
|
||||
|
||||
spec: object # Required; kind-specific payload
|
||||
```
|
||||
|
||||
### 3.1 Versioning policy
|
||||
|
||||
- `schemaVersion` — integer bumped on breaking envelope changes
|
||||
- `apiVersion` — string group/version (e.g. `adp.io/v1alpha1`, `adp.io/v1`)
|
||||
- `metadata.version` — application SemVer (independent of protocol version)
|
||||
- Breaking changes MUST increment `apiVersion` (e.g. v1alpha1 → v1)
|
||||
- Deprecated fields MUST include `deprecated: true` and SHOULD include `x-sunset` date
|
||||
|
||||
### 3.2 Authoring formats
|
||||
|
||||
Manifests MAY be authored as YAML (`.application.yaml`) or JSON (`.application.json`). Semantics MUST be identical. Runtimes MUST NOT consume YAML directly; compilers MUST normalise to JSON before validation.
|
||||
|
||||
---
|
||||
|
||||
## 4. Application spec
|
||||
|
||||
An `Application` spec MUST contain at minimum:
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `site.title` | YES | Application display name |
|
||||
| `templates` | YES | At least one template |
|
||||
| `routes` | YES | At least one route |
|
||||
|
||||
All other fields are OPTIONAL.
|
||||
|
||||
### 4.1 Site (`spec.site`)
|
||||
|
||||
```yaml
|
||||
site:
|
||||
title: string # Required
|
||||
description: string # Optional
|
||||
ui: shadcn # UI framework hint; default shadcn
|
||||
routeBaseDocs: /docs # Base path for documentation routes
|
||||
```
|
||||
|
||||
### 4.2 Theme (`spec.theme`)
|
||||
|
||||
Theme MAY use preset shorthand or reference a DTCG token file:
|
||||
|
||||
```yaml
|
||||
theme:
|
||||
preset: zinc # Tailwind-style preset name
|
||||
radius: md # none | sm | md | lg | xl
|
||||
darkMode: true
|
||||
# OR
|
||||
tokens: $yaml: ./theme.tokens.json # DTCG 2025.10 file
|
||||
```
|
||||
|
||||
### 4.3 Navigation
|
||||
|
||||
ADP separates **top tab bar** from **sidebar source**:
|
||||
|
||||
```yaml
|
||||
navigation:
|
||||
tabs: # Top tab bar (Mintlify-style)
|
||||
- tab: Documentation
|
||||
groups:
|
||||
- group: Getting Started
|
||||
pages: [index, getting-started/installation]
|
||||
|
||||
content:
|
||||
docs:
|
||||
dir: ./docs
|
||||
nav:
|
||||
mode: auto # auto | manual — sidebar source
|
||||
sort: filesystem
|
||||
collapsible: true
|
||||
maxDepth: 4
|
||||
```
|
||||
|
||||
- **`navigation.tabs`** — top-level tab bar with grouped pages
|
||||
- **`content.docs.nav.mode: auto`** — sidebar generated from filesystem scan
|
||||
- **`content.docs.nav.mode: manual`** — sidebar driven by `navigation.tabs` groups
|
||||
- Renderers MUST honour `content.docs.nav.mode` when building sidebar (R29)
|
||||
|
||||
### 4.4 Components (`spec.components`)
|
||||
|
||||
```yaml
|
||||
components:
|
||||
header_main:
|
||||
type: Header # MUST exist in component catalog
|
||||
props:
|
||||
logoText: PraisonAIUI
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Component IDs MUST be unique within the manifest
|
||||
- `type` MUST match an entry in the published component catalog
|
||||
- `props` MUST validate against the catalog JSON Schema for that type
|
||||
|
||||
### 4.5 Templates (`spec.templates`)
|
||||
|
||||
```yaml
|
||||
templates:
|
||||
docs:
|
||||
layout: ThreeColumnLayout
|
||||
slots:
|
||||
header: { ref: header_main }
|
||||
left: { ref: sidebar_docs }
|
||||
main: { type: DocContent }
|
||||
right: null # Explicitly hide slot
|
||||
|
||||
landing:
|
||||
layout: FlexibleLayout
|
||||
zones:
|
||||
hero:
|
||||
- type: HeroBanner
|
||||
props: { title: "Welcome" }
|
||||
```
|
||||
|
||||
Rules:
|
||||
- `layout` MUST be a known layout type in the catalog
|
||||
- Each slot value MUST be `{ ref: id }`, `{ type: ComponentType }`, or `null`
|
||||
- Zones MUST use zone names valid for `FlexibleLayout`
|
||||
- A template MUST declare either `slots` or `zones`, not both (R13)
|
||||
|
||||
### 4.6 Routes (`spec.routes`)
|
||||
|
||||
```yaml
|
||||
routes:
|
||||
- match: /docs/changelog
|
||||
template: changelog
|
||||
slots: # Optional per-route slot overrides
|
||||
right: null
|
||||
- match: /docs/**
|
||||
template: docs
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Routes MUST be ordered most-specific-first
|
||||
- `match` MUST be a glob pattern
|
||||
- `template` MUST reference a key in `spec.templates`
|
||||
- Renderers MUST resolve routes at runtime using compiled `route-manifest.json`
|
||||
|
||||
### 4.7 Content (`spec.content`)
|
||||
|
||||
```yaml
|
||||
content:
|
||||
docs:
|
||||
dir: ./docs
|
||||
include: ["**/*.md", "**/*.mdx"]
|
||||
exclude: []
|
||||
nav:
|
||||
mode: auto
|
||||
sort: filesystem
|
||||
collapsible: true
|
||||
maxDepth: 4
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Component catalog
|
||||
|
||||
Implementations MUST publish a `component-catalog.schema.json` (meta-schema) and a catalog instance such as [`catalogs/default.catalog.json`](./catalogs/default.catalog.json):
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "adp-default-catalog",
|
||||
"version": "1.0.0",
|
||||
"components": {
|
||||
"Header": {
|
||||
"description": "Site header with logo and navigation",
|
||||
"props": {
|
||||
"type": "object",
|
||||
"properties": { "logoText": { "type": "string" } },
|
||||
"required": ["logoText"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"aria": { "role": "banner" },
|
||||
"slots": ["header"]
|
||||
}
|
||||
},
|
||||
"layouts": {
|
||||
"ThreeColumnLayout": {
|
||||
"description": "Header + sidebar + main + TOC + footer",
|
||||
"slots": ["header", "left", "main", "right", "footer"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Custom component types MUST be registered via plugin schema layers. Unknown types MUST cause validation failure in strict mode.
|
||||
|
||||
---
|
||||
|
||||
## 6. Compilation pipeline
|
||||
|
||||
A conforming compiler MUST:
|
||||
|
||||
1. Parse YAML or JSON manifest
|
||||
2. Normalise to JSON
|
||||
3. Validate against `application.schema.json` (JSON Schema 2020-12)
|
||||
4. Validate cross-reference rules (see [validation-rules.md](./validation-rules.md))
|
||||
5. Validate component props against catalog schema
|
||||
6. Emit runtime manifests:
|
||||
- `ui-config.json` — site, components, templates, theme, navigation
|
||||
- `route-manifest.json` — ordered routes with template refs
|
||||
- `docs-nav.json` — filesystem-derived navigation (if mode auto)
|
||||
|
||||
---
|
||||
|
||||
## 7. File composition
|
||||
|
||||
Manifests MAY include external files using Backstage-style placeholders:
|
||||
|
||||
```yaml
|
||||
theme:
|
||||
$yaml: ./theme.yaml
|
||||
|
||||
navigation:
|
||||
$yaml: ./navigation.yaml
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Paths MUST be relative to the containing manifest
|
||||
- Path traversal (`../../`) MUST be rejected
|
||||
- Supported placeholders: `$yaml:`, `$json:`, `$text:`
|
||||
|
||||
---
|
||||
|
||||
## 8. Extensions
|
||||
|
||||
Vendor extensions MUST use the `x-adp-` prefix:
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
site:
|
||||
x-adp-customAnalytics: true
|
||||
```
|
||||
|
||||
Implementations MUST preserve unknown `x-adp-*` fields. Non-prefixed unknown fields MUST be rejected in strict mode.
|
||||
|
||||
---
|
||||
|
||||
## 9. Conformance
|
||||
|
||||
An implementation is **ADP-conformant** if and only if it:
|
||||
|
||||
1. Accepts manifests with `apiVersion: adp.io/v1alpha1` and `kind: Application`
|
||||
2. Validates all MUST-level requirements in this spec and validation-rules.md
|
||||
3. Rejects manifests with unknown fields in strict mode
|
||||
4. Emits runtime JSON manifests from valid Application specs
|
||||
5. Resolves routes to templates at render time
|
||||
|
||||
---
|
||||
|
||||
## 10. Related documents
|
||||
|
||||
- [Research report](../application-design-protocol-report.md)
|
||||
- [Implementation status](./implementation-status.md)
|
||||
- [PraisonAIUI mapping](./aiui-mapping.md)
|
||||
- [JSON Schema: application](./schema/application.schema.json)
|
||||
- [JSON Schema: component catalog](./schema/component-catalog.schema.json)
|
||||
- [JSON Schema: theme](./schema/theme.schema.json)
|
||||
- [Examples](./examples/)
|
||||
@@ -0,0 +1,115 @@
|
||||
# ADP Validation Rules
|
||||
|
||||
Cross-reference validation rules for Application Design Protocol manifests. These constraints cannot be expressed in JSON Schema alone and MUST be enforced by conforming compilers.
|
||||
|
||||
Rules use RFC 2119 keywords: MUST, MUST NOT, SHOULD, MAY.
|
||||
|
||||
Inspired by AWP validation rules R1–R32 pattern.
|
||||
|
||||
---
|
||||
|
||||
## Envelope rules
|
||||
|
||||
| Rule | Level | Description |
|
||||
|------|-------|-------------|
|
||||
| **R1** | MUST | `schemaVersion` MUST equal `1` for `apiVersion: adp.io/v1alpha1` |
|
||||
| **R2** | MUST | `kind` MUST be `Application` for root manifests |
|
||||
| **R3** | MUST | `metadata.name` MUST be unique within a deployment scope |
|
||||
| **R4** | MUST NOT | Unknown top-level fields outside `x-adp-*` namespace in strict mode |
|
||||
|
||||
## Component rules
|
||||
|
||||
| Rule | Level | Description |
|
||||
|------|-------|-------------|
|
||||
| **R5** | MUST | Every `spec.components` entry MUST have a unique ID |
|
||||
| **R6** | MUST | Every component `type` MUST exist in the published component catalog |
|
||||
| **R7** | MUST | Component `props` MUST validate against catalog JSON Schema for that type |
|
||||
| **R8** | MUST NOT | A component ID be referenced by `ref:` unless it exists in `spec.components` |
|
||||
| **R9** | SHOULD | Unused components (defined but never referenced) SHOULD emit a warning |
|
||||
|
||||
## Template rules
|
||||
|
||||
| Rule | Level | Description |
|
||||
|------|-------|-------------|
|
||||
| **R10** | MUST | Every `spec.templates` entry MUST declare a valid `layout` from the catalog |
|
||||
| **R11** | MUST | Slot names in `templates.*.slots` MUST be valid for the declared layout |
|
||||
| **R12** | MUST | Zone names in `templates.*.zones` MUST be valid for `FlexibleLayout` |
|
||||
| **R13** | MUST | Each template MUST declare either `slots` or `zones`, not both |
|
||||
| **R14** | MUST | Inline slot `{ type: X }` types MUST exist in the component catalog |
|
||||
| **R15** | MUST | Slot `{ ref: id }` MUST resolve to a component in `spec.components` |
|
||||
|
||||
## Route rules
|
||||
|
||||
| Rule | Level | Description |
|
||||
|------|-------|-------------|
|
||||
| **R16** | MUST | Every route `template` MUST reference a key in `spec.templates` |
|
||||
| **R17** | MUST | Routes MUST be ordered most-specific-first (longer/more precise patterns before wildcards) |
|
||||
| **R18** | MUST | Route slot overrides MUST use valid slot names for the target template's layout |
|
||||
| **R19** | SHOULD | At least one route SHOULD match the site root or docs base path |
|
||||
|
||||
## Content and navigation rules
|
||||
|
||||
| Rule | Level | Description |
|
||||
|------|-------|-------------|
|
||||
| **R20** | MUST | If `content.docs.dir` is declared, the directory MUST exist at compile time |
|
||||
| **R21** | MUST | If `content.docs.nav.mode` is `manual`, `navigation.tabs` MUST be non-empty |
|
||||
| **R22** | SHOULD | If `content.docs.nav.mode` is `auto`, a content source MUST be declared |
|
||||
|
||||
Navigation model:
|
||||
|
||||
- **`navigation.tabs`** — top tab bar (Mintlify-style)
|
||||
- **`content.docs.nav.mode`** — sidebar source: `auto` (filesystem scan) or `manual` (use `navigation.tabs` groups)
|
||||
|
||||
## File include rules
|
||||
|
||||
| Rule | Level | Description |
|
||||
|------|-------|-------------|
|
||||
| **R23** | MUST NOT | `$yaml:`, `$json:`, or `$text:` paths traverse outside the project root |
|
||||
| **R24** | MUST | Included files MUST parse as valid YAML/JSON/text respectively |
|
||||
| **R25** | MUST NOT | Circular file includes (A includes B includes A) |
|
||||
|
||||
## Theme rules
|
||||
|
||||
| Rule | Level | Description |
|
||||
|------|-------|-------------|
|
||||
| **R26** | MUST | If `theme.tokens` is declared, the referenced file MUST exist and validate against DTCG 2025.10 |
|
||||
| **R27** | MUST NOT | Both `theme.preset` and `theme.tokens` be declared simultaneously |
|
||||
|
||||
## Renderer conformance (target — not yet implemented in PraisonAIUI)
|
||||
|
||||
These rules define the target behaviour for ADP-conformant renderers. See [implementation-status.md](./implementation-status.md).
|
||||
|
||||
| Rule | Level | Description |
|
||||
|------|-------|-------------|
|
||||
| **R28** | MUST | Renderers MUST resolve the active template from `route-manifest.json` at runtime |
|
||||
| **R29** | MUST | Renderers MUST honour `content.docs.nav.mode` when building sidebar |
|
||||
| **R30** | MUST | Renderers MUST apply ARIA roles from catalog for each rendered component |
|
||||
|
||||
---
|
||||
|
||||
## Validation pipeline order
|
||||
|
||||
```
|
||||
1. Parse YAML/JSON
|
||||
2. Resolve $yaml/$json/$text includes (R23–R25)
|
||||
3. JSON Schema validate (application.schema.json)
|
||||
4. Cross-ref rules R1–R27
|
||||
5. Component props validate against catalog (R6–R7)
|
||||
6. Emit runtime manifests
|
||||
7. Runtime conformance R28–R30 (renderer responsibility)
|
||||
```
|
||||
|
||||
## Error format
|
||||
|
||||
Validation failures MUST report:
|
||||
|
||||
```json
|
||||
{
|
||||
"rule": "R8",
|
||||
"path": "spec.templates.docs.slots.header.ref",
|
||||
"message": "Component ref 'missing_header' not found in spec.components",
|
||||
"severity": "error"
|
||||
}
|
||||
```
|
||||
|
||||
Warnings (R9) use `"severity": "warning"`.
|
||||
@@ -0,0 +1,427 @@
|
||||
# Application Design Protocol (ADP) — Research Report
|
||||
|
||||
**Version:** 1.0 (draft)
|
||||
**Date:** August 2026
|
||||
**Scope:** UI, layout, navigation, theming — declarative application design from text manifests
|
||||
**Status:** Research and recommendation; not yet a published standard
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive summary
|
||||
|
||||
There is no single existing standard for **designing full application UI from a declarative text file**. Instead, the problem is addressed in fragments:
|
||||
|
||||
- **Docs-site generators** (Mintlify, PraisonAIUI, Hugo) declare navigation and layout in config files.
|
||||
- **Form libraries** (JSON Forms, RJSF) declare layout trees from JSON Schema.
|
||||
- **Agent UI protocols** (A2UI, AG-UI) declare streaming component surfaces for LLM-generated UI.
|
||||
- **Catalog standards** (OpenUI, Backstage) declare component metadata without runtime layout.
|
||||
- **Design token standards** (DTCG 2025.10) declare theming without structure.
|
||||
|
||||
**Recommendation:** Define an **Application Design Protocol (ADP)** that synthesises proven patterns from 46 surveyed standards into a UI-only manifest format.
|
||||
|
||||
PraisonAIUI already implements approximately 60% of this model. ADP formalises, extends, and publishes what exists today.
|
||||
|
||||
---
|
||||
|
||||
## 2. Problem statement
|
||||
|
||||
### 2.1 What we need
|
||||
|
||||
A protocol that lets a developer (or LLM) describe an application's visual design — pages, navigation, components, themes, routes — in **one or more text files**, without writing application code.
|
||||
|
||||
### 2.2 What exists today (fragmented)
|
||||
|
||||
| Need | Who solves it | Gap |
|
||||
|------|---------------|-----|
|
||||
| Docs site from config | Mintlify, PraisonAIUI, Docusaurus | No shared spec; Mintlify uses JSON, others use YAML or JS |
|
||||
| Component catalog | OpenUI, A2UI | No layout or routing |
|
||||
| Layout trees | JSON Forms uiSchema | Forms only, not app shells |
|
||||
| Theming | DTCG, Material 3 | Tokens only, no pages |
|
||||
| Agent-generated UI | A2UI | Runtime streaming, not human-authored static sites |
|
||||
| Software metadata | Backstage | Catalog entities, not UI composition |
|
||||
|
||||
### 2.3 Design goals for ADP
|
||||
|
||||
1. **Human-readable** — editable without IDE plugins
|
||||
2. **LLM-safe** — structural schema rejects unknown fields
|
||||
3. **Portable** — same manifest renders on any ADP-compliant runtime
|
||||
4. **Validatable** — fail fast at build time, not in the browser
|
||||
5. **Extensible** — plugins add catalog entries, not inline code
|
||||
6. **Accessible** — ARIA hints required in component catalog
|
||||
|
||||
---
|
||||
|
||||
## 3. Standards landscape — 46 protocols in 10 categories
|
||||
|
||||
Each entry describes **how UI/layout/navigation is declared** — the primary comparison dimension.
|
||||
|
||||
### Category A: Web platform baseline (5)
|
||||
|
||||
| Standard | Format | How UI/layout/navigation is declared | Schema | Versioning |
|
||||
|----------|--------|--------------------------------------|--------|------------|
|
||||
| **HTML + CSS** | `.html` + `.css` | Semantic elements; CSS Flexbox/Grid; `<nav>` + links | Living spec grammar | Browser Baseline |
|
||||
| **Web Components + DSD** | HTML + JS | Custom element tree; slots; Declarative Shadow DOM | DOM IDL | Platform flags |
|
||||
| **WAI-ARIA + APG** | HTML attributes | Landmarks, widget roles, keyboard patterns | WAI-ARIA 1.2 | Rec track |
|
||||
| **DTCG Design Tokens** | JSON | Token groups: `$value`, `$type`, `{alias}` refs — **no layout** | DTCG 2025.10 | Date editions |
|
||||
| **OpenUI** | YAML/JSON | Component catalog: `components{id: props}` — **no layout tree** | Prop definitions | Per-library version |
|
||||
|
||||
### Category B: Schema-driven forms and layout (3)
|
||||
|
||||
| Standard | Format | How UI/layout/navigation is declared | Schema | Versioning |
|
||||
|----------|--------|--------------------------------------|--------|------------|
|
||||
| **JSON Forms** | JSON Schema + UI Schema | `VerticalLayout`, `Control`, `Categorization` with `scope` pointers | JSON Schema + uiSchema | Package SemVer |
|
||||
| **RJSF** | JSON Schema + uiSchema | Schema-tree mirroring; `ui:widget`, `ui:options` | React convention | Package SemVer |
|
||||
| **Google A2UI** | JSONL stream | Flat adjacency-list; `createSurface`; catalog-constrained types | JSON Schema catalog | v0.9.1 / v1.0 RC |
|
||||
|
||||
### Category C: Docs-site generators (6)
|
||||
|
||||
| Standard | Format | How UI/layout/navigation is declared | Schema | Versioning |
|
||||
|----------|--------|--------------------------------------|--------|------------|
|
||||
| **Mintlify** | JSON (`docs.json`) | `navigation.groups[].pages[]`; `$schema` URL; `$ref` splitting | Published JSON Schema URL | Platform |
|
||||
| **PraisonAIUI** | YAML | `components` → `templates{layout, slots, zones}` → `routes` | Pydantic (internal) | `schemaVersion: 1` |
|
||||
| **Docusaurus** | JS config | `themeConfig.navbar`, plugin sidebar config | TypeScript types | Package SemVer |
|
||||
| **Hugo** | TOML/YAML | `menu.main[]`, theme selection | Hugo schema | Release train |
|
||||
| **Jekyll** | YAML | Frontmatter per page; `_data/` nav | Conventions | SemVer |
|
||||
| **Gatsby** | JS/TS | `siteMetadata` + `plugins[]` | JS object | Major versions |
|
||||
|
||||
### Category D: Native declarative UI — reference only (5)
|
||||
|
||||
| Standard | Format | Serialised wire format? | Navigation model |
|
||||
|----------|--------|-------------------------|------------------|
|
||||
| **Jetpack Compose** | Kotlin | No | `NavHost` + routes |
|
||||
| **SwiftUI** | Swift | No | `NavigationStack` |
|
||||
| **Flutter** | Dart | No | `Router` / `Navigator` |
|
||||
| **Qt QML** | `.qml` | Yes | `StackView`, bindings |
|
||||
| **XAML** | XML | Yes (dialect-fragmented) | `Frame`, `NavigationView` |
|
||||
|
||||
### Category E: Design systems (2)
|
||||
|
||||
| Standard | Format | Theming model |
|
||||
|----------|--------|---------------|
|
||||
| **Material Design 3** | DTCG JSON | reference → system → component token tiers |
|
||||
| **Bootstrap 5.3** | SCSS → CSS vars | Sass maps → `--bs-*` custom properties |
|
||||
|
||||
### Category F: Design-to-code bridges — not source-of-truth (3)
|
||||
|
||||
| Standard | Output | Why not ADP source |
|
||||
|----------|--------|-------------------|
|
||||
| **Figma MCP** | React+Tailwind context | Proprietary; bridge only |
|
||||
| **Webflow export** | HTML/CSS/JS ZIP | No schema; paid export |
|
||||
| **Framer export** | React bundles | Platform lock-in |
|
||||
|
||||
### Category G: Component documentation (1)
|
||||
|
||||
| Standard | Format | Navigation |
|
||||
|----------|--------|------------|
|
||||
| **Storybook CSF 3** | TS modules | Sidebar via `title` path hierarchy |
|
||||
|
||||
### Category H: Configuration and packaging (12)
|
||||
|
||||
| Standard | Format | Composition mechanism | ADP borrow |
|
||||
|----------|--------|----------------------|------------|
|
||||
| **Kubernetes CRD** | YAML | `apiVersion/kind/spec`; structural OpenAPI | Structural validation |
|
||||
| **Helm** | YAML + templates | Subcharts; values merge; `values.schema.json` | Defaults + overrides |
|
||||
| **Backstage catalog** | YAML | `$yaml:`/`$json:` includes; relation graph | Envelope + includes |
|
||||
| **OpenAPI 3.1** | YAML/JSON | `$ref`, `components` registry | JSON Schema foundation |
|
||||
| **AsyncAPI 3.x** | YAML/JSON | Channels → messages | Future event UI |
|
||||
| **JSON Schema 2020-12** | JSON | `$ref`, `$defs`, conditionals | Validation dialect |
|
||||
| **CloudEvents** | JSON envelope | Fixed envelope + opaque payload | Future event wrapper |
|
||||
| **Docker Compose** | YAML | Multi-file merge; profiles | Override stacking |
|
||||
| **CUE** | CUE | Unify schema + data | Power-user compile layer |
|
||||
| **Jsonnet** | Jsonnet → JSON | `import` + merge | Theme variants |
|
||||
| **Pkl** | `.pkl` | `amends`, mixins | Typed config model |
|
||||
| **OCI Artifact** | JSON manifest | Multi-layer blobs | Bundle packaging |
|
||||
|
||||
### Category I: Agent and workflow protocols (7)
|
||||
|
||||
| Standard | Format | UI/state declaration | ADP borrow |
|
||||
|----------|--------|---------------------|------------|
|
||||
| **MCP** | JSON-RPC | Tool schemas — no UI layout | Future widget actions |
|
||||
| **AG-UI** | Streaming JSON | EventType enum; snapshots + deltas | Streaming model |
|
||||
| **A2A** | Proto → JSON-RPC | Agent Cards | Transport |
|
||||
| **AWP** | Multi-file YAML | 7 layers; rules R1–R32 | Layer + validation model |
|
||||
| **LangGraph** | workflow.yaml | Graph nodes + edges | Layout graph |
|
||||
| **AutoGen** | ComponentModel JSON | `{provider, type, version, config}` | Pluggable components |
|
||||
| **CrewAI** | JSONC/YAML | Agent arrays — weak schema | Anti-pattern |
|
||||
|
||||
### Category J: Application packaging (2)
|
||||
|
||||
| Standard | Format | Application declaration |
|
||||
|----------|--------|------------------------|
|
||||
| **OAM** | YAML | `Application` with `components[]`, `traits[]` |
|
||||
| **CNAB** | bundle.json | Parameters, credentials, actions |
|
||||
|
||||
---
|
||||
|
||||
## 4. How they do it — mechanism comparison matrix
|
||||
|
||||
| Mechanism | Standards | How it works | ADP |
|
||||
|-----------|-----------|--------------|-----|
|
||||
| **Envelope document** | K8s, Backstage, OAM, AWP | `apiVersion` + `kind` + `metadata` + `spec` | Adopt |
|
||||
| **Component registry + ref** | PraisonAIUI, OpenUI | Named map; templates use `ref:` | Adopt |
|
||||
| **Layout + slots** | PraisonAIUI, JSON Forms | Layout type + named regions | Adopt (unified) |
|
||||
| **Route → template** | PraisonAIUI, Flutter | URL glob → template | Adopt |
|
||||
| **Navigation tree** | Mintlify, Storybook | Hierarchical groups → pages | Adopt |
|
||||
| **Design token file** | DTCG, Material 3 | Token JSON with aliases | Adopt |
|
||||
| **Catalog allowlist** | A2UI, OpenUI | Closed component vocabulary | Adopt |
|
||||
| **Schema + uiSchema split** | JSON Forms, RJSF | Data vs presentation | Partial |
|
||||
| **Streaming surfaces** | A2UI, AG-UI | Incremental JSON updates | Future |
|
||||
| **Layered spec** | AWP, Helm | Separate files merged at compile | Adopt |
|
||||
| **File includes** | Backstage, Mintlify | `$yaml:` / `$ref` external files | Adopt |
|
||||
| **Structural pruning** | K8s CRD | Reject unknown fields | Adopt |
|
||||
| **Validation rules R1–Rn** | AWP | Cross-ref beyond JSON Schema | Adopt |
|
||||
| **JS executable config** | Docusaurus, Gatsby | Code config | Reject |
|
||||
| **Visual canvas** | Figma, Webflow | Proprietary designer | Reject |
|
||||
| **Code-only UI** | Compose, SwiftUI | No wire format | Out of scope |
|
||||
|
||||
---
|
||||
|
||||
## 5. Side-by-side: four closest analogues
|
||||
|
||||
| Dimension | PraisonAIUI | Mintlify | A2UI | Backstage |
|
||||
|-----------|-------------|----------|------|-----------|
|
||||
| **Authoring format** | YAML | JSON | JSON (stream) | YAML |
|
||||
| **Envelope** | `schemaVersion` only | Flat JSON | Message `version` | `apiVersion/kind/metadata/spec` |
|
||||
| **Navigation** | `navigation.tabs[]` + filesystem | `navigation.groups[]` | N/A | N/A (metadata catalog) |
|
||||
| **Components** | `components{id: type, props}` | N/A | Catalog + adjacency list | Entity kinds |
|
||||
| **Layout** | `templates{layout, slots, zones}` | Theme only | Surfaces | N/A |
|
||||
| **Routes** | `routes{match, template}` | Page paths in nav | N/A | N/A |
|
||||
| **Schema published** | Pydantic only | `$schema` URL | JSON Schema catalog | JSON Schema per kind |
|
||||
| **Runtime** | React SPA | Hosted platform | Any renderer | Catalog API |
|
||||
| **Primary use** | Docs sites | Docs sites | Agent-generated UI | Software inventory |
|
||||
|
||||
**Synthesis:** ADP combines PraisonAIUI's composition model, Mintlify's schema publishing, A2UI's catalog allowlist, and Backstage's envelope and file includes.
|
||||
|
||||
---
|
||||
|
||||
## 6. YAML vs alternatives — structured debate
|
||||
|
||||
### 6.1 The assumption
|
||||
|
||||
YAML is the best authoring format for application design manifests.
|
||||
|
||||
### 6.2 Counter-evidence
|
||||
|
||||
**Mintlify** — the closest commercial docs-site analogue — uses **JSON** (`docs.json`) with a published `$schema` URL for IDE autocomplete. This works well for flat-to-moderately-nested config but becomes verbose for deep layout trees.
|
||||
|
||||
**A2UI** uses **JSON** as both authoring and wire format because LLMs emit structured JSON reliably and ambiguity is unacceptable at runtime.
|
||||
|
||||
**Docusaurus and Gatsby** use **JavaScript/TypeScript** config for programmatic power — but this sacrifices portability and LLM generation safety.
|
||||
|
||||
### 6.3 Format comparison
|
||||
|
||||
| Format | Strengths | Weaknesses for ADP |
|
||||
|--------|-----------|-------------------|
|
||||
| **YAML** | Comments; readable nesting; LLM-friendly; PraisonAIUI continuity | Ambiguous typing; merge key footguns; no native DRY |
|
||||
| **JSON** | Unambiguous; `$schema` IDE support; Mintlify-proven | No comments; verbose for layout trees |
|
||||
| **TOML** | Clear flat tables (Hugo) | Poor for nested templates/zones |
|
||||
| **CUE / Jsonnet / Pkl** | Typed composition without templating | Learning curve; not LLM-default |
|
||||
| **HCL** | Modules, expressions | Ecosystem-bound; overkill for UI-only |
|
||||
| **JS/TS** | Full programmatic power | Not portable; not LLM-safe |
|
||||
|
||||
### 6.4 Recommendation
|
||||
|
||||
```
|
||||
Author (human/LLM) → YAML (default) or JSON (alternate)
|
||||
↓
|
||||
ADP compiler/validator (JSON Schema 2020-12)
|
||||
↓
|
||||
Normalised JSON manifest (runtime)
|
||||
↓
|
||||
UI renderer (catalog-constrained)
|
||||
```
|
||||
|
||||
- **Primary authoring:** YAML — best for nested `templates` / `zones` trees
|
||||
- **Alternate authoring:** JSON — for teams preferring Mintlify-style `$schema` tooling
|
||||
- **Never at runtime:** YAML — always compile to JSON before serve
|
||||
- **Composition:** `$yaml:` file includes (Backstage), not Helm Go templates
|
||||
- **Power users:** Optional CUE/Jsonnet compiles to YAML before validation
|
||||
|
||||
**Verdict:** YAML remains the recommended default, but ADP MUST accept JSON as an equivalent authoring format with identical semantics.
|
||||
|
||||
---
|
||||
|
||||
## 7. ADP design principles
|
||||
|
||||
1. **Declarative, not executable** — manifests describe intent; runtimes render; no inline code or templating logic in core spec.
|
||||
2. **Schema-first** — JSON Schema 2020-12 is canonical; YAML is authoring convenience.
|
||||
3. **Structural strictness** — unknown fields rejected in strict mode (K8s CRD pattern).
|
||||
4. **Catalog allowlist** — component `type` must exist in published catalog (A2UI pattern).
|
||||
5. **Envelope consistency** — every document uses `apiVersion`, `kind`, `metadata`, `spec`.
|
||||
6. **Separation of concerns** — theme, navigation, components, templates, routes are distinct spec sections.
|
||||
7. **Compile, don't interpret** — YAML → validated JSON manifest → static assets at build time.
|
||||
8. **Accessibility by default** — catalog entries include ARIA role hints (WAI-ARIA pattern).
|
||||
9. **Versioned protocol** — `schemaVersion` integer + `apiVersion` string; breaking changes bump version.
|
||||
10. **Extension namespace** — `x-adp-*` prefix reserved; plugins register catalog layers.
|
||||
11. **File composition** — `$yaml:` includes for modular configs (Backstage pattern).
|
||||
12. **Route-driven rendering** — URL resolution selects template; no hardcoded layout in renderer.
|
||||
|
||||
---
|
||||
|
||||
## 8. Proposed ADP document model
|
||||
|
||||
```yaml
|
||||
schemaVersion: 1
|
||||
apiVersion: adp.io/v1alpha1
|
||||
kind: Application
|
||||
|
||||
metadata:
|
||||
name: my-docs-site
|
||||
version: "1.0.0"
|
||||
labels: {}
|
||||
|
||||
spec:
|
||||
site:
|
||||
title: "My Site"
|
||||
description: "..."
|
||||
theme:
|
||||
preset: zinc # or DTCG token ref
|
||||
radius: md
|
||||
darkMode: true
|
||||
navigation:
|
||||
mode: auto # auto | manual
|
||||
tabs: [...]
|
||||
components:
|
||||
header_main:
|
||||
type: Header
|
||||
props: { logoText: "..." }
|
||||
templates:
|
||||
docs:
|
||||
layout: ThreeColumnLayout
|
||||
slots:
|
||||
header: { ref: header_main }
|
||||
main: { type: DocContent }
|
||||
routes:
|
||||
- match: "/docs/**"
|
||||
template: docs
|
||||
content:
|
||||
docs:
|
||||
dir: "./docs"
|
||||
```
|
||||
|
||||
### Data flow
|
||||
|
||||
```
|
||||
application.yaml → Parse → JSON Schema validate → Cross-ref rules (R1–R30)
|
||||
→ Compile → ui-config.json + route-manifest.json + docs-nav.json
|
||||
→ React / static renderer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. PraisonAIUI alignment and gaps
|
||||
|
||||
### Already aligned
|
||||
|
||||
| PraisonAIUI today | ADP equivalent |
|
||||
|-------------------|----------------|
|
||||
| `schemaVersion: 1` | `schemaVersion` + `apiVersion` |
|
||||
| `components` + `ref:` | `spec.components` + slot refs |
|
||||
| `templates.layout` + `slots` | `spec.templates` |
|
||||
| `routes` | `spec.routes` |
|
||||
| `site.theme` | `spec.theme` |
|
||||
| Compiler → JSON manifests | ADP compile pipeline |
|
||||
| Pydantic validation | JSON Schema + cross-ref rules |
|
||||
|
||||
### Gaps to close
|
||||
|
||||
| Gap | ADP fix |
|
||||
|-----|---------|
|
||||
| No published JSON Schema | Publish `application.schema.json` |
|
||||
| `navigation.tabs` compiled but not rendered | Mandate runtime consumption in spec |
|
||||
| `route-manifest.json` not used for routing | Mandate route-driven template resolution |
|
||||
| Dual slots + zones models | Unified: zones = ordered slot arrays |
|
||||
| Closed hardcoded component types | Published `component-catalog.schema.json` |
|
||||
| No envelope (`kind`, `metadata`) | Add Backstage-style envelope |
|
||||
|
||||
---
|
||||
|
||||
## 10. Migration path
|
||||
|
||||
Existing `aiui.template.yaml` files remain valid. Migration steps:
|
||||
|
||||
1. Add `apiVersion: adp.io/v1alpha1` and `kind: Application` (optional during transition).
|
||||
2. Wrap top-level keys under `spec:` (compiler accepts both shapes during v1alpha1).
|
||||
3. Rename `site` fields unchanged; map `navigation`, `templates`, `routes` as-is.
|
||||
4. Publish catalog; register custom component types via plugin schema layers.
|
||||
|
||||
See [adp/aiui-mapping.md](./adp/aiui-mapping.md) for field-by-field mapping.
|
||||
|
||||
---
|
||||
|
||||
## 11. Open questions
|
||||
|
||||
1. **Should ADP standardise layout type names** (`ThreeColumnLayout`) or allow runtime-defined layouts?
|
||||
2. **Should navigation support both filesystem-auto and manual tabs** in one spec, or require a mode switch?
|
||||
3. **Should agent-driven surfaces (A2UI) be a separate `kind: Surface`** or embedded in Application?
|
||||
4. **Should theme reference DTCG token files directly** or use simplified preset shorthand?
|
||||
|
||||
---
|
||||
|
||||
## 12. Appendix A — Standards index
|
||||
|
||||
| # | Standard | URL |
|
||||
|---|----------|-----|
|
||||
| 1 | A2UI | https://a2ui.org/ |
|
||||
| 2 | AG-UI | https://docs.ag-ui.com/ |
|
||||
| 3 | AWP | https://github.com/veegee82/agent-workflow-protocol |
|
||||
| 4 | AsyncAPI | https://www.asyncapi.com/ |
|
||||
| 5 | Backstage catalog | https://backstage.io/docs/features/software-catalog/descriptor-format |
|
||||
| 6 | Bootstrap | https://getbootstrap.com/ |
|
||||
| 7 | CloudEvents | https://cloudevents.io/ |
|
||||
| 8 | CNAB | https://cnab.io/ |
|
||||
| 9 | CrewAI | https://docs.crewai.com/ |
|
||||
| 10 | CUE | https://cuelang.org/ |
|
||||
| 11 | Docusaurus | https://docusaurus.io/ |
|
||||
| 12 | DTCG Design Tokens | https://www.designtokens.org/ |
|
||||
| 13 | Figma Code Connect | https://developers.figma.com/docs/code-connect/ |
|
||||
| 14 | Framer | https://www.framer.com/ |
|
||||
| 15 | Gatsby | https://www.gatsbyjs.com/ |
|
||||
| 16 | Helm | https://helm.sh/ |
|
||||
| 17 | Hugo | https://gohugo.io/ |
|
||||
| 18 | Jekyll | https://jekyllrb.com/ |
|
||||
| 19 | JSON Forms | https://jsonforms.io/ |
|
||||
| 20 | JSON Schema | https://json-schema.org/ |
|
||||
| 21 | Kubernetes CRD | https://kubernetes.io/docs/concepts/extend-kubernetes/api-extension/custom-resources/ |
|
||||
| 22 | LangGraph | https://langchain-ai.github.io/langgraph/ |
|
||||
| 23 | Material Design 3 | https://m3.material.io/ |
|
||||
| 24 | MCP | https://modelcontextprotocol.io/ |
|
||||
| 25 | Mintlify | https://mintlify.com/docs |
|
||||
| 26 | OAM | https://oam.dev/ |
|
||||
| 27 | OCI Artifact | https://opencontainers.org/ |
|
||||
| 28 | OpenAPI | https://www.openapis.org/ |
|
||||
| 29 | OpenUI | https://openuispec.org/ |
|
||||
| 30 | Pkl | https://pkl-lang.org/ |
|
||||
| 31 | PraisonAIUI | https://github.com/MervinPraison/PraisonAIUI |
|
||||
| 32 | RJSF | https://rjsf-team.github.io/react-jsonschema-form/ |
|
||||
| 33 | Storybook CSF | https://storybook.js.org/docs/api/csf |
|
||||
| 34 | W3C ARIA APG | https://www.w3.org/WAI/ARIA/apg/ |
|
||||
| 35 | Web Components | https://web.dev/articles/declarative-shadow-dom |
|
||||
| 36 | Webflow | https://webflow.com/ |
|
||||
| 37 | AutoGen | https://microsoft.github.io/autogen/ |
|
||||
| 38 | A2A | https://google.github.io/A2A/ |
|
||||
| 39 | Jsonnet | https://jsonnet.org/ |
|
||||
| 40 | Docker Compose | https://docs.docker.com/compose/ |
|
||||
| 41 | Qt QML | https://doc.qt.io/qt-6/qmlapplications.html |
|
||||
| 42 | XAML | https://learn.microsoft.com/en-us/windows/apps/develop/platform/xaml/xaml-overview |
|
||||
| 43 | Jetpack Compose | https://developer.android.com/develop/ui/compose |
|
||||
| 44 | SwiftUI | https://developer.apple.com/xcode/swiftui/ |
|
||||
| 45 | HTML + CSS | https://html.spec.whatwg.org/ |
|
||||
| 46 | Flutter | https://flutter.dev/ |
|
||||
|
||||
---
|
||||
|
||||
## 13. Conclusion
|
||||
|
||||
ADP is feasible and largely prefigured by PraisonAIUI's existing architecture. The research across 46 standards yields a clear synthesis:
|
||||
|
||||
- **Envelope** from Backstage/K8s
|
||||
- **Composition** from PraisonAIUI (components → templates → routes)
|
||||
- **Catalog** from A2UI/OpenUI
|
||||
- **Theming** from DTCG
|
||||
- **Validation** from AWP (structural schema + R-rules)
|
||||
- **Authoring** in YAML (default) with JSON alternate (Mintlify pattern)
|
||||
|
||||
**Status:** Research draft — see [adp/implementation-status.md](./adp/implementation-status.md) for implementation gaps.
|
||||
|
||||
The draft spec, JSON Schema files, and example manifests are published alongside this report in [`docs/protocols/adp/`](./adp/).
|
||||
+45
-23
@@ -2,6 +2,12 @@
|
||||
|
||||
> Each example teaches exactly **one new concept** — no overlap.
|
||||
|
||||
See also: [`docs/examples/basic.md`](../docs/examples/basic.md) and [`docs/examples/advanced.md`](../docs/examples/advanced.md).
|
||||
|
||||
**Note:** Some folders share numeric prefixes (`02-*`, `19-*`) — treat the path name as the canonical ID, not the number alone.
|
||||
|
||||
---
|
||||
|
||||
## Python Track
|
||||
|
||||
| # | Name | Unique Concept | Run |
|
||||
@@ -15,16 +21,32 @@
|
||||
| 07 | [provider-praisonai](python/07-provider-praisonai/) | Custom `BaseProvider` subclass, `RunEvent` protocol | `aiui run app.py` |
|
||||
| 08 | [streaming](python/08-streaming/) | PraisonAI Agent streaming via `stream_emitter` | `aiui run app.py` |
|
||||
| 09 | [widget](python/09-widget/) | Copilot/sidebar mode | `aiui run app.py` |
|
||||
| 10 | [feature-showcase](python/10-feature-showcase/) | All protocol features seeded, `create_app()` | `python app.py` |
|
||||
| 11 | [ui-integration](python/11-ui-integration/) | Gradio ASGI mount, Streamlit iframe, REST embedding | `python app.py` |
|
||||
| 12 | [agent-dashboard](python/12-agent-dashboard/) | OpenClaw-style rendered HTML admin panel | `python app.py` |
|
||||
| 10 | [feature-showcase](python/10-feature-showcase/) | All protocol features seeded, `create_app()` | `aiui run app.py` |
|
||||
| 11 | [ui-integration](python/11-ui-integration/) | Gradio ASGI mount, Streamlit iframe, REST embedding | `aiui run app.py` |
|
||||
| 12 | ~~agent-dashboard~~ → Use [dashboard-test](python/15-dashboard-test/) | ~~Removed in v0.5.0 (XSS vulnerability)~~ | See example 15 |
|
||||
| 13 | [real-dashboard](python/13-real-dashboard/) | Real PraisonAI Agent + native dashboard + live metrics | `aiui run app.py --style dashboard` |
|
||||
| 19 | [email-bot](python/19-email-bot/) | `EmailBot` IMAP/SMTP + agent replies + subject commands | `python app.py` |
|
||||
| 20 | [email-channel](python/20-email-channel/) | Email as AIUI dashboard channel via `/api/channels` | `python app.py` |
|
||||
| 21 | [agentmail-bot](python/21-agentmail-bot/) | `AgentMailBot` API-first email + programmatic inbox lifecycle | `python app.py` |
|
||||
| 22 | [agentmail-channel](python/22-agentmail-channel/) | AgentMail as AIUI dashboard channel via `/api/channels` | `python app.py` |
|
||||
| 25 | [clean-chat](python/25-clean-chat/) | `set_dashboard(sidebar=False)` — dashboard chat without left nav | `aiui run app.py` |
|
||||
| 26 | [custom-theme-chat](python/26-custom-theme-chat/) | `register_theme()` — dynamic custom theme (teal/cyan) | `aiui run app.py` |
|
||||
| 14 | [all-features](python/14-all-features/) | Integration test suite for all 16 feature APIs | `python app.py` |
|
||||
| 15 | [dashboard-test](python/15-dashboard-test/) | Protocol-first page registration, view registry | `python app.py` |
|
||||
| 16 | [gateway-integration](python/16-gateway-integration/) | `AIUIGateway` — real agent execution + WebSocket streaming | `python app.py` |
|
||||
| 17 | [three-column-demo](python/17-three-column-demo/) | Three-column feature explorer with gateway | `python app.py` |
|
||||
| 18 | [full-chat](python/18-full-chat/) | `aiui.set_pages()` page curation + custom explorer page | `python app.py` |
|
||||
| 19 | [components-showcase](python/19-components-showcase/) | All 54 UI component types in one dashboard | `aiui run app.py --style dashboard` |
|
||||
| 20 | [email-bot](python/20-email-bot/) | `EmailBot` IMAP/SMTP + agent replies + subject commands | `python app.py` |
|
||||
| 21 | [email-channel](python/21-email-channel/) | Email as AIUI dashboard channel via `/api/channels` | `aiui run app.py` |
|
||||
| 22 | [agentmail-bot](python/22-agentmail-bot/) | `AgentMailBot` API-first email + programmatic inbox lifecycle | `python app.py` |
|
||||
| 23 | [agentmail-channel](python/23-agentmail-channel/) | AgentMail as AIUI dashboard channel via `/api/channels` | `aiui run app.py` |
|
||||
| 24 | [e2e-components-test](python/24-e2e-components-test/) | Components, docs, and chat rendering in one app | `python app.py` |
|
||||
| 25 | [custom-design](python/25-custom-design/) | Every chat design knob (`set_theme`, branding, layout) | `aiui run app.py` |
|
||||
| 26 | [clean-chat](python/26-clean-chat/) | `set_dashboard(sidebar=False)` — dashboard chat without left nav | `aiui run app.py` |
|
||||
| 27 | [custom-theme-chat](python/27-custom-theme-chat/) | `register_theme()` — dynamic custom theme (teal/cyan) | `aiui run app.py` |
|
||||
| 28 | [multica-style](python/28-multica-style/) | Multica-inspired UI features (glass, gradients, motion) | `python app.py` |
|
||||
| 29 | [full-extensibility](python/29-full-extensibility/) | All extension points: pages, forms, `set_custom_js`, plugins | `python app.py` |
|
||||
| 30 | [a2ui-canvas](python/30-a2ui-canvas/) | A2UI canvas + `send_a2ui_messages` integration | `python app.py` |
|
||||
| 31 | [image-preview](python/31-image-preview/) | `ImageAgent` inline chat image preview via `MESSAGE_ELEMENT` | `aiui run app.py` |
|
||||
| — | [external-agent-dashboard](python/external-agent-dashboard/) | Minimal external agent on dashboard shell | `aiui run app.py` |
|
||||
| — | [platform-board](python/platform-board/) | Kanban board from PraisonAI Platform issues API | `aiui run app.py` |
|
||||
| — | [praisonai-claw-board](python/praisonai-claw-board/) | Kanban board from `aiui` jobs store | `aiui run app.py` |
|
||||
| — | [video-studio](python/video-studio/) | Video Studio — YAML scenes via Video engine sidecar | `aiui run app.py` |
|
||||
|
||||
---
|
||||
|
||||
@@ -33,22 +55,22 @@
|
||||
| # | Name | Shows | Run |
|
||||
|---|---|---|---|
|
||||
| 01 | [chat](yaml/01-chat/) | Zero-code YAML chat agent | `aiui run chat.yaml` |
|
||||
| 02 | [clean-chat](yaml/02-clean-chat/) | `dashboard.sidebar: false` — dashboard chat without left nav (YAML parity) | `aiui run clean-chat.yaml` |
|
||||
| 02 | [clean-chat](yaml/02-clean-chat/) | `dashboard.sidebar: false` — dashboard chat without left nav | `aiui run clean-chat.yaml` |
|
||||
| 02 | [profiles](yaml/02-profiles/) | YAML agent profiles / persona switching | `aiui run chat.yaml` |
|
||||
| 03 | [tools](yaml/03-tools/) | Built-in YAML tool registration | `aiui run chat.yaml` |
|
||||
| 04 | [features](yaml/04-features/) | Feature flags and dashboard options in YAML | `aiui run chat.yaml` |
|
||||
| 12 | [playground](yaml/12-playground/) | YAML agent playground with multiple profiles | `aiui run chat.yaml --style agents` |
|
||||
|
||||
---
|
||||
|
||||
## YAML Track — Site Templates
|
||||
|
||||
| # | Name | Shows |
|
||||
|---|---|---|
|
||||
| 01 | minimal | 5-line YAML → live docs site |
|
||||
| 02 | with-sidebar | `DocsSidebar` slot, directory-based navigation |
|
||||
| 03 | with-toc | `Toc` slot, right-column table of contents |
|
||||
| 04 | multi-section | Multiple content directories and grouped routes |
|
||||
| 05 | custom-homepage | `Hero` + `FeatureList` slot components |
|
||||
| 06 | fullstack | Header/Footer refs, CTA buttons, dark mode |
|
||||
| 07 | seo-config | `<meta>` tags, `<title>` per-page, Open Graph |
|
||||
| 08 | api-reference | `ApiSidebar`, route-level slot overrides |
|
||||
| 09 | typescript-custom | Custom TypeScript component registration |
|
||||
| 10 | i18n-ready | Multi-language content directories |
|
||||
| 11 | monorepo | Workspaces, shared components, per-package docs |
|
||||
| # | Name | Shows | Run |
|
||||
|---|---|---|---|
|
||||
| 05 | [minimal](yaml/05-minimal/) | 5-line YAML → live docs site | `aiui build --config aiui.template.yaml` |
|
||||
| 06 | [blocks](yaml/06-blocks/) | shadcn component dependency installation | `aiui build --config aiui.template.yaml` |
|
||||
| 07 | [colorful](yaml/07-colorful/) | Colourful marketing theme with hero zones | `aiui build --config aiui.template.yaml` |
|
||||
| 08 | [corporate](yaml/08-corporate/) | Corporate docs with SEO, i18n, and a11y fields | `aiui build --config aiui.template.yaml` |
|
||||
| 09 | [dark-modern](yaml/09-dark-modern/) | Dark-mode modern docs layout | `aiui build --config aiui.template.yaml` |
|
||||
| 10 | [developer-portal](yaml/10-developer-portal/) | API reference sidebar and route overrides | `aiui build --config aiui.template.yaml` |
|
||||
| 11 | [full-featured](yaml/11-full-featured/) | Full enterprise config (SEO, i18n, a11y, search) | `aiui build --config aiui.template.yaml` |
|
||||
@@ -40,8 +40,10 @@ def _get_client():
|
||||
_client = AsyncOpenAI(api_key=api_key)
|
||||
return _client
|
||||
|
||||
# Keep per-session context in memory (DataStore handles persistence)
|
||||
_contexts: dict[str, list[dict]] = {}
|
||||
# Use SessionGovernor for bounded session memory management
|
||||
from praisonaiui.session_governor import SessionGovernor
|
||||
|
||||
_governor = SessionGovernor(max_turns=20, max_sessions=100)
|
||||
|
||||
|
||||
@aiui.starters
|
||||
@@ -66,20 +68,18 @@ async def on_message(message: str):
|
||||
"""Send the message to OpenAI and stream the response."""
|
||||
session_id = message.session_id if hasattr(message, "session_id") else "default"
|
||||
|
||||
# Build conversation context
|
||||
if session_id not in _contexts:
|
||||
_contexts[session_id] = [
|
||||
{"role": "system", "content": "You are a helpful, concise assistant."}
|
||||
]
|
||||
|
||||
_contexts[session_id].append({"role": "user", "content": str(message)})
|
||||
# Add user message to bounded session context
|
||||
_governor.add_message(session_id, {"role": "user", "content": str(message)})
|
||||
|
||||
await aiui.think("Thinking...")
|
||||
|
||||
# Get bounded context for LLM
|
||||
messages = _governor.get_context(session_id)
|
||||
|
||||
# Stream from OpenAI
|
||||
stream = await _get_client().chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=_contexts[session_id],
|
||||
messages=messages,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
@@ -90,8 +90,8 @@ async def on_message(message: str):
|
||||
full_response += delta.content
|
||||
await aiui.stream_token(delta.content)
|
||||
|
||||
# Save assistant response to context
|
||||
_contexts[session_id].append({"role": "assistant", "content": full_response})
|
||||
# Save assistant response to bounded context
|
||||
_governor.add_message(session_id, {"role": "assistant", "content": full_response})
|
||||
|
||||
# Feedback buttons
|
||||
await aiui.action_buttons([
|
||||
|
||||
@@ -15,8 +15,20 @@ Run:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
import praisonaiui as aiui
|
||||
|
||||
# Support --datastore json flag as documented
|
||||
if "--datastore" in sys.argv:
|
||||
try:
|
||||
idx = sys.argv.index("--datastore")
|
||||
if idx + 1 < len(sys.argv) and sys.argv[idx + 1] == "json":
|
||||
from praisonaiui.datastore import JSONFileDataStore
|
||||
aiui.set_datastore(JSONFileDataStore())
|
||||
except (IndexError, ImportError):
|
||||
pass
|
||||
|
||||
# Lazy agent — only created when first message is sent
|
||||
_agent = None
|
||||
|
||||
|
||||
@@ -17,10 +17,10 @@ Run:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import praisonaiui as aiui
|
||||
from praisonaiui.provider import BaseProvider, RunEvent, RunEventType
|
||||
|
||||
|
||||
# === Option 1: Provider-based (Direct Mode) ===
|
||||
# Uses PraisonAI Agent directly with streaming bridge
|
||||
|
||||
@@ -65,13 +65,11 @@ class FullFeaturedProvider(BaseProvider):
|
||||
|
||||
def calculate(expression: str) -> str:
|
||||
"""Evaluate a math expression safely."""
|
||||
from praisonaiui.math_eval import eval_math_expression
|
||||
|
||||
try:
|
||||
# Safe evaluation of basic math
|
||||
allowed = set("0123456789+-*/.() ")
|
||||
if all(c in allowed for c in expression):
|
||||
result = eval(expression)
|
||||
return f"Result: {result}"
|
||||
return "Error: Only basic math operations allowed"
|
||||
result = eval_math_expression(expression)
|
||||
return f"Result: {result}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@@ -89,7 +87,8 @@ class FullFeaturedProvider(BaseProvider):
|
||||
yield RunEvent(type=RunEventType.REASONING_STARTED)
|
||||
yield RunEvent(type=RunEventType.REASONING_STEP, step="Analyzing the request...")
|
||||
|
||||
# Set up streaming bridge
|
||||
# Get the running event loop for thread-safe queue operations
|
||||
loop = asyncio.get_running_loop()
|
||||
event_queue = asyncio.Queue()
|
||||
_has_streaming = False
|
||||
|
||||
@@ -99,27 +98,32 @@ class FullFeaturedProvider(BaseProvider):
|
||||
def _on_stream(evt):
|
||||
if evt.type == SET.DELTA_TEXT and evt.content:
|
||||
if evt.is_reasoning:
|
||||
event_queue.put_nowait(RunEvent(
|
||||
run_event = RunEvent(
|
||||
type=RunEventType.REASONING_STEP,
|
||||
step=evt.content,
|
||||
))
|
||||
)
|
||||
# Use thread-safe queue operation
|
||||
loop.call_soon_threadsafe(event_queue.put_nowait, run_event)
|
||||
else:
|
||||
event_queue.put_nowait(RunEvent(
|
||||
run_event = RunEvent(
|
||||
type=RunEventType.RUN_CONTENT,
|
||||
token=evt.content,
|
||||
))
|
||||
)
|
||||
loop.call_soon_threadsafe(event_queue.put_nowait, run_event)
|
||||
elif evt.type == SET.DELTA_TOOL_CALL and evt.tool_call:
|
||||
event_queue.put_nowait(RunEvent(
|
||||
run_event = RunEvent(
|
||||
type=RunEventType.TOOL_CALL_STARTED,
|
||||
name=evt.tool_call.get("name"),
|
||||
args=evt.tool_call.get("arguments"),
|
||||
))
|
||||
)
|
||||
loop.call_soon_threadsafe(event_queue.put_nowait, run_event)
|
||||
elif evt.type == SET.TOOL_CALL_END and evt.tool_call:
|
||||
event_queue.put_nowait(RunEvent(
|
||||
run_event = RunEvent(
|
||||
type=RunEventType.TOOL_CALL_COMPLETED,
|
||||
name=evt.tool_call.get("name"),
|
||||
result=evt.tool_call.get("result"),
|
||||
))
|
||||
)
|
||||
loop.call_soon_threadsafe(event_queue.put_nowait, run_event)
|
||||
|
||||
agent.stream_emitter.add_callback(_on_stream)
|
||||
agent.stream_emitter.enable_metrics()
|
||||
@@ -129,26 +133,57 @@ class FullFeaturedProvider(BaseProvider):
|
||||
|
||||
yield RunEvent(type=RunEventType.REASONING_COMPLETED)
|
||||
|
||||
# Run the agent
|
||||
try:
|
||||
response = await asyncio.to_thread(agent.chat, message)
|
||||
full_response = str(response)
|
||||
except Exception as exc:
|
||||
yield RunEvent(type=RunEventType.RUN_ERROR, error=str(exc))
|
||||
return
|
||||
finally:
|
||||
if _has_streaming:
|
||||
# Run the agent concurrently with event draining
|
||||
if _has_streaming:
|
||||
async def _drain_events():
|
||||
"""Drain events concurrently with agent execution."""
|
||||
while True:
|
||||
event = await event_queue.get()
|
||||
if event is None:
|
||||
break
|
||||
yield event
|
||||
|
||||
chat_task = asyncio.create_task(asyncio.to_thread(agent.chat, message))
|
||||
|
||||
async def _signal_end():
|
||||
try:
|
||||
await chat_task
|
||||
finally:
|
||||
await event_queue.put(None)
|
||||
|
||||
signal_task = asyncio.create_task(_signal_end())
|
||||
|
||||
try:
|
||||
# Drain events while agent is running
|
||||
async for event in _drain_events():
|
||||
yield event
|
||||
|
||||
# Wait for agent completion
|
||||
response = await chat_task
|
||||
full_response = str(response)
|
||||
except Exception as exc:
|
||||
yield RunEvent(type=RunEventType.RUN_ERROR, error=str(exc))
|
||||
return
|
||||
finally:
|
||||
try:
|
||||
agent.stream_emitter.remove_callback(_on_stream)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Drain streamed events
|
||||
while not event_queue.empty():
|
||||
# Ensure signal task is cleaned up
|
||||
if not signal_task.done():
|
||||
signal_task.cancel()
|
||||
try:
|
||||
await signal_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
else:
|
||||
# Fallback without streaming
|
||||
try:
|
||||
yield event_queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
response = await asyncio.to_thread(agent.chat, message)
|
||||
full_response = str(response)
|
||||
except Exception as exc:
|
||||
yield RunEvent(type=RunEventType.RUN_ERROR, error=str(exc))
|
||||
return
|
||||
|
||||
# If no tokens were streamed, emit the full response
|
||||
if event_queue.empty() and not _has_streaming:
|
||||
|
||||
@@ -10,14 +10,17 @@ Run:
|
||||
aiui run app.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
import praisonaiui as aiui
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '_shared'))
|
||||
from stream_bridge import StreamBridge
|
||||
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.streaming.events import StreamEventType
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"praisonaiagents package required. Install with: pip install praisonaiagents"
|
||||
@@ -70,36 +73,22 @@ async def on_message(message: str):
|
||||
|
||||
await aiui.think("Thinking...")
|
||||
|
||||
# Set up a streaming callback that forwards tokens to PraisonAIUI
|
||||
token_queue = asyncio.Queue()
|
||||
# Set up thread-safe streaming bridge
|
||||
bridge = StreamBridge()
|
||||
|
||||
def on_stream_event(event):
|
||||
"""Callback invoked by the Agent's stream_emitter for each token."""
|
||||
if event.type == StreamEventType.DELTA_TEXT and event.content:
|
||||
token_queue.put_nowait(event.content)
|
||||
elif event.type == StreamEventType.FIRST_TOKEN and event.content:
|
||||
token_queue.put_nowait(event.content)
|
||||
elif event.type == StreamEventType.STREAM_END:
|
||||
token_queue.put_nowait(None) # Sentinel
|
||||
|
||||
# Register callback
|
||||
agent.stream_emitter.add_callback(on_stream_event)
|
||||
# Register callback using the bridge
|
||||
callback = bridge.emitter_callback()
|
||||
agent.stream_emitter.add_callback(callback)
|
||||
agent.stream_emitter.enable_metrics()
|
||||
|
||||
try:
|
||||
# Run agent.chat in a thread (it's synchronous)
|
||||
chat_task = asyncio.get_event_loop().run_in_executor(
|
||||
None, lambda: agent.chat(str(message), stream=True)
|
||||
# Run agent.chat concurrently with token consumption
|
||||
chat_task = asyncio.create_task(
|
||||
asyncio.to_thread(lambda: agent.chat(str(message), stream=True))
|
||||
)
|
||||
|
||||
# Drain tokens as they arrive and stream to UI
|
||||
while True:
|
||||
try:
|
||||
token = await asyncio.wait_for(token_queue.get(), timeout=60.0)
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
if token is None: # Sentinel from STREAM_END
|
||||
break
|
||||
# Consume tokens as they arrive and stream to UI
|
||||
async for token in bridge.consume():
|
||||
await aiui.stream_token(token)
|
||||
|
||||
# Wait for the chat to fully complete
|
||||
@@ -107,7 +96,8 @@ async def on_message(message: str):
|
||||
|
||||
finally:
|
||||
# Clean up callback
|
||||
agent.stream_emitter.remove_callback(on_stream_event)
|
||||
agent.stream_emitter.remove_callback(callback)
|
||||
bridge.cancel()
|
||||
|
||||
|
||||
@aiui.cancel
|
||||
|
||||
@@ -11,18 +11,21 @@ Demonstrates:
|
||||
|
||||
Run:
|
||||
pip install praisonaiui
|
||||
python app.py
|
||||
aiui run app.py
|
||||
# Dashboard at http://localhost:8082
|
||||
# Set HOST=0.0.0.0 for Docker/LAN demos (defaults to 127.0.0.1)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import sys
|
||||
|
||||
import uvicorn
|
||||
|
||||
from praisonaiui.server import create_app
|
||||
import uvicorn
|
||||
|
||||
# Use shared seed data helper
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from _shared.console import safe_print as print
|
||||
from _shared.seed_data import seed_demo_data
|
||||
|
||||
seed_demo_data()
|
||||
@@ -31,4 +34,5 @@ if __name__ == "__main__":
|
||||
app = create_app()
|
||||
print("✅ Full Dashboard at http://localhost:8082")
|
||||
print(" API: http://localhost:8082/api/features")
|
||||
uvicorn.run(app, host="0.0.0.0", port=8082, log_level="info")
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
uvicorn.run(app, host=host, port=8082, log_level="info")
|
||||
@@ -20,6 +20,7 @@ import sys
|
||||
|
||||
# Use shared seed data helper
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from _shared.console import safe_print as print
|
||||
from _shared.seed_data import seed_demo_data
|
||||
|
||||
|
||||
@@ -171,7 +172,8 @@ def run_standalone():
|
||||
|
||||
aiui_app.routes.insert(0, Route("/", landing, methods=["GET"]))
|
||||
print("✅ UI Integration Demo at http://localhost:8082")
|
||||
uvicorn.run(aiui_app, host="0.0.0.0", port=8082, log_level="info")
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
uvicorn.run(aiui_app, host=host, port=8082, log_level="info")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,493 +0,0 @@
|
||||
"""Agent Dashboard — OpenClaw/agent-ui style admin panel.
|
||||
|
||||
What's New (vs full-dashboard/):
|
||||
• Rendered HTML dashboard — not just JSON APIs
|
||||
• Sidebar navigation with live page switching
|
||||
• Status badges, toggle buttons, real-time data
|
||||
• Glassmorphism dark theme
|
||||
• All data from PraisonAIUI feature APIs
|
||||
|
||||
Run:
|
||||
PYTHONPATH=src python app.py
|
||||
# Dashboard at http://localhost:8082
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import uvicorn
|
||||
from starlette.responses import HTMLResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
from praisonaiui.server import create_app
|
||||
|
||||
# Use shared seed data helper
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from _shared.seed_data import seed_demo_data
|
||||
|
||||
|
||||
# ── HTML Template ────────────────────────────────────────────────────
|
||||
|
||||
DASHBOARD_HTML = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Agent Dashboard — PraisonAIUI</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #0a0a12;
|
||||
--bg-card: #12121f;
|
||||
--bg-hover: #1a1a2e;
|
||||
--border: #2a2a4a;
|
||||
--text-primary: #e0e0e8;
|
||||
--text-secondary: #94a3b8;
|
||||
--accent-purple: #7c3aed;
|
||||
--accent-cyan: #06b6d4;
|
||||
--accent-green: #22c55e;
|
||||
--accent-amber: #f59e0b;
|
||||
--accent-red: #ef4444;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Inter', -apple-system, sans-serif; background: var(--bg-primary);
|
||||
color: var(--text-primary); display: flex; min-height: 100vh; }
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar { width: 240px; background: var(--bg-card); border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column; position: fixed; height: 100vh; z-index: 10; }
|
||||
.sidebar-header { padding: 1.25rem; border-bottom: 1px solid var(--border); }
|
||||
.sidebar-header h1 { font-size: 1.1rem; font-weight: 700;
|
||||
background: linear-gradient(90deg, var(--accent-purple), var(--accent-cyan));
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.sidebar-header p { font-size: 0.7rem; color: var(--text-secondary); margin-top: 2px; }
|
||||
.nav-group { padding: 0.75rem 0; }
|
||||
.nav-group-label { font-size: 0.65rem; color: var(--text-secondary); text-transform: uppercase;
|
||||
letter-spacing: 0.1em; padding: 0 1rem; margin-bottom: 0.25rem; font-weight: 600; }
|
||||
.nav-item { display: flex; align-items: center; gap: 0.6rem; padding: 0.5rem 1rem;
|
||||
cursor: pointer; transition: all 0.2s; font-size: 0.85rem; color: var(--text-secondary);
|
||||
border-left: 3px solid transparent; }
|
||||
.nav-item:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
.nav-item.active { background: var(--bg-hover); color: var(--accent-purple);
|
||||
border-left-color: var(--accent-purple); font-weight: 500; }
|
||||
.nav-icon { font-size: 1rem; width: 1.5rem; text-align: center; }
|
||||
.nav-badge { margin-left: auto; background: var(--accent-purple); color: white;
|
||||
font-size: 0.65rem; padding: 1px 6px; border-radius: 10px; }
|
||||
|
||||
/* Main content */
|
||||
.main { margin-left: 240px; flex: 1; padding: 1.5rem; }
|
||||
.page-header { margin-bottom: 1.5rem; }
|
||||
.page-header h2 { font-size: 1.5rem; font-weight: 600; }
|
||||
.page-header p { color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.25rem; }
|
||||
|
||||
/* Cards grid */
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem; margin-bottom: 1.5rem; }
|
||||
.stat-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 1.25rem; transition: border-color 0.3s; }
|
||||
.stat-card:hover { border-color: var(--accent-purple); }
|
||||
.stat-label { font-size: 0.75rem; color: var(--text-secondary); text-transform: uppercase;
|
||||
letter-spacing: 0.05em; }
|
||||
.stat-value { font-size: 1.75rem; font-weight: 700; margin-top: 0.25rem;
|
||||
background: linear-gradient(90deg, var(--accent-purple), var(--accent-cyan));
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.stat-sub { font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.25rem; }
|
||||
|
||||
/* Table */
|
||||
.data-table { width: 100%; background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: 10px; overflow: hidden; }
|
||||
.data-table th { text-align: left; padding: 0.75rem 1rem; font-size: 0.75rem;
|
||||
text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-secondary);
|
||||
background: var(--bg-hover); border-bottom: 1px solid var(--border); font-weight: 600; }
|
||||
.data-table td { padding: 0.75rem 1rem; font-size: 0.85rem; border-bottom: 1px solid var(--border); }
|
||||
.data-table tr:last-child td { border-bottom: none; }
|
||||
.data-table tr:hover td { background: rgba(124, 58, 237, 0.05); }
|
||||
|
||||
/* Badges */
|
||||
.badge { padding: 2px 8px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; }
|
||||
.badge-online { background: rgba(34, 197, 94, 0.15); color: var(--accent-green); }
|
||||
.badge-offline { background: rgba(239, 68, 68, 0.15); color: var(--accent-red); }
|
||||
.badge-enabled { background: rgba(34, 197, 94, 0.15); color: var(--accent-green); }
|
||||
.badge-disabled { background: rgba(148, 163, 184, 0.15); color: var(--text-secondary); }
|
||||
.badge-running { background: rgba(6, 182, 212, 0.15); color: var(--accent-cyan); }
|
||||
.badge-platform { background: rgba(124, 58, 237, 0.1); color: var(--accent-purple); }
|
||||
|
||||
/* Toggle switch */
|
||||
.toggle { position: relative; width: 36px; height: 20px; cursor: pointer; }
|
||||
.toggle input { display: none; }
|
||||
.toggle-slider { position: absolute; inset: 0; background: #333; border-radius: 10px;
|
||||
transition: 0.3s; }
|
||||
.toggle-slider::before { content: ''; position: absolute; left: 2px; top: 2px; width: 16px;
|
||||
height: 16px; background: white; border-radius: 50%; transition: 0.3s; }
|
||||
.toggle input:checked + .toggle-slider { background: var(--accent-purple); }
|
||||
.toggle input:checked + .toggle-slider::before { transform: translateX(16px); }
|
||||
|
||||
/* Section */
|
||||
.section { margin-bottom: 1.5rem; }
|
||||
.section-title { font-size: 0.85rem; color: var(--text-secondary); font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.75rem;
|
||||
display: flex; align-items: center; gap: 0.5rem; }
|
||||
.section-count { background: var(--accent-purple); color: white; font-size: 0.65rem;
|
||||
padding: 1px 6px; border-radius: 10px; }
|
||||
|
||||
/* Page display */
|
||||
.page { display: none; }
|
||||
.page.active { display: block; }
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); } }
|
||||
.page.active { animation: fadeIn 0.3s ease; }
|
||||
|
||||
/* Feature list */
|
||||
.feature-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1rem; }
|
||||
.feature-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 1.25rem; transition: all 0.3s; }
|
||||
.feature-card:hover { border-color: var(--accent-purple); transform: translateY(-2px); }
|
||||
.feature-name { font-weight: 600; margin-bottom: 0.25rem; }
|
||||
.feature-desc { font-size: 0.8rem; color: var(--text-secondary); }
|
||||
.feature-routes { margin-top: 0.75rem; font-size: 0.75rem; color: var(--text-secondary); }
|
||||
.feature-route { font-family: monospace; font-size: 0.7rem; color: var(--accent-cyan);
|
||||
display: block; margin-top: 2px; }
|
||||
|
||||
/* Empty state */
|
||||
.empty { text-align: center; padding: 3rem; color: var(--text-secondary); }
|
||||
.empty-icon { font-size: 3rem; margin-bottom: 1rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h1>🤖 Agent Dashboard</h1>
|
||||
<p>PraisonAIUI Admin</p>
|
||||
</div>
|
||||
<nav>
|
||||
<div class="nav-group">
|
||||
<div class="nav-group-label">Overview</div>
|
||||
<div class="nav-item active" data-page="overview">
|
||||
<span class="nav-icon">📊</span> Overview
|
||||
</div>
|
||||
<div class="nav-item" data-page="features">
|
||||
<span class="nav-icon">⚡</span> Features
|
||||
<span class="nav-badge" id="nav-feature-count">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-group">
|
||||
<div class="nav-group-label">Messaging</div>
|
||||
<div class="nav-item" data-page="channels">
|
||||
<span class="nav-icon">📡</span> Channels
|
||||
<span class="nav-badge" id="nav-channel-count">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-group">
|
||||
<div class="nav-group-label">Infrastructure</div>
|
||||
<div class="nav-item" data-page="nodes">
|
||||
<span class="nav-icon">🖥️</span> Nodes
|
||||
<span class="nav-badge" id="nav-node-count">—</span>
|
||||
</div>
|
||||
<div class="nav-item" data-page="instances">
|
||||
<span class="nav-icon">📻</span> Instances
|
||||
<span class="nav-badge" id="nav-instance-count">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-group">
|
||||
<div class="nav-group-label">Automation</div>
|
||||
<div class="nav-item" data-page="schedules">
|
||||
<span class="nav-icon">⏰</span> Schedules
|
||||
<span class="nav-badge" id="nav-schedule-count">—</span>
|
||||
</div>
|
||||
<div class="nav-item" data-page="skills">
|
||||
<span class="nav-icon">🧩</span> Skills
|
||||
<span class="nav-badge" id="nav-skill-count">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<!-- OVERVIEW PAGE -->
|
||||
<div class="page active" id="page-overview">
|
||||
<div class="page-header">
|
||||
<h2>Overview</h2>
|
||||
<p>Real-time system status at a glance</p>
|
||||
</div>
|
||||
<div class="stats-grid" id="overview-stats"></div>
|
||||
</div>
|
||||
|
||||
<!-- FEATURES PAGE -->
|
||||
<div class="page" id="page-features">
|
||||
<div class="page-header">
|
||||
<h2>Features</h2>
|
||||
<p>All registered protocol feature modules</p>
|
||||
</div>
|
||||
<div class="feature-grid" id="features-grid"></div>
|
||||
</div>
|
||||
|
||||
<!-- CHANNELS PAGE -->
|
||||
<div class="page" id="page-channels">
|
||||
<div class="page-header">
|
||||
<h2>Channels</h2>
|
||||
<p>Multi-platform messaging channels</p>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">Active Channels <span class="section-count" id="channel-count">0</span></div>
|
||||
<table class="data-table" id="channels-table">
|
||||
<thead><tr>
|
||||
<th>Name</th><th>Platform</th><th>Status</th><th>Enabled</th><th>Last Activity</th>
|
||||
</tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NODES PAGE -->
|
||||
<div class="page" id="page-nodes">
|
||||
<div class="page-header">
|
||||
<h2>Nodes</h2>
|
||||
<p>Execution nodes and agent bindings</p>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">Registered Nodes <span class="section-count" id="node-count">0</span></div>
|
||||
<table class="data-table" id="nodes-table">
|
||||
<thead><tr>
|
||||
<th>Name</th><th>Host</th><th>Platform</th><th>Status</th><th>Agents</th><th>Policy</th>
|
||||
</tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- INSTANCES PAGE -->
|
||||
<div class="page" id="page-instances">
|
||||
<div class="page-header">
|
||||
<h2>Instances</h2>
|
||||
<p>Connected clients and workers</p>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">Connected Instances <span class="section-count" id="instance-count">0</span></div>
|
||||
<table class="data-table" id="instances-table">
|
||||
<thead><tr>
|
||||
<th>ID</th><th>Host</th><th>Platform</th><th>Mode</th><th>Roles</th><th>Last Seen</th>
|
||||
</tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SCHEDULES PAGE -->
|
||||
<div class="page" id="page-schedules">
|
||||
<div class="page-header">
|
||||
<h2>Schedules</h2>
|
||||
<p>Cron jobs and scheduled tasks</p>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">Scheduled Jobs <span class="section-count" id="schedule-count">0</span></div>
|
||||
<table class="data-table" id="schedules-table">
|
||||
<thead><tr>
|
||||
<th>Name</th><th>Schedule</th><th>Agent</th><th>Enabled</th><th>Runs</th><th>Last Run</th>
|
||||
</tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SKILLS PAGE -->
|
||||
<div class="page" id="page-skills">
|
||||
<div class="page-header">
|
||||
<h2>Skills</h2>
|
||||
<p>Registered agent capabilities</p>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">Agent Skills <span class="section-count" id="skill-count">0</span></div>
|
||||
<table class="data-table" id="skills-table">
|
||||
<thead><tr>
|
||||
<th>Name</th><th>ID</th><th>Enabled</th><th>Config</th>
|
||||
</tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// ── Navigation ──────────────────────────────────────────────────────
|
||||
document.querySelectorAll('.nav-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('active'));
|
||||
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||||
item.classList.add('active');
|
||||
document.getElementById('page-' + item.dataset.page).classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Data loading ────────────────────────────────────────────────────
|
||||
async function fetchJSON(url) {
|
||||
const r = await fetch(url);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function timeAgo(ts) {
|
||||
if (!ts) return '—';
|
||||
const s = Math.floor(Date.now() / 1000 - ts);
|
||||
if (s < 60) return s + 's ago';
|
||||
if (s < 3600) return Math.floor(s / 60) + 'm ago';
|
||||
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
|
||||
return Math.floor(s / 86400) + 'd ago';
|
||||
}
|
||||
|
||||
async function loadOverview() {
|
||||
const [features, channels, nodes, instances, schedules, skills] = await Promise.all([
|
||||
fetchJSON('/api/features'),
|
||||
fetchJSON('/api/channels'),
|
||||
fetchJSON('/api/nodes'),
|
||||
fetchJSON('/api/instances'),
|
||||
fetchJSON('/api/schedules'),
|
||||
fetchJSON('/api/skills'),
|
||||
]);
|
||||
|
||||
// Nav badges
|
||||
document.getElementById('nav-feature-count').textContent = features.count;
|
||||
document.getElementById('nav-channel-count').textContent = channels.count;
|
||||
document.getElementById('nav-node-count').textContent = nodes.count;
|
||||
document.getElementById('nav-instance-count').textContent = instances.count;
|
||||
document.getElementById('nav-schedule-count').textContent = schedules.count;
|
||||
document.getElementById('nav-skill-count').textContent = skills.count;
|
||||
|
||||
// Overview stats
|
||||
const statsEl = document.getElementById('overview-stats');
|
||||
const runningChannels = channels.channels.filter(c => c.running).length;
|
||||
const onlineNodes = nodes.nodes.filter(n => n.status === 'online').length;
|
||||
statsEl.innerHTML = `
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Features</div>
|
||||
<div class="stat-value">${features.count}</div>
|
||||
<div class="stat-sub">Protocol modules active</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Channels</div>
|
||||
<div class="stat-value">${channels.count}</div>
|
||||
<div class="stat-sub">${runningChannels} running</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Nodes</div>
|
||||
<div class="stat-value">${nodes.count}</div>
|
||||
<div class="stat-sub">${onlineNodes} online</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Instances</div>
|
||||
<div class="stat-value">${instances.count}</div>
|
||||
<div class="stat-sub">Connected clients & workers</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Schedules</div>
|
||||
<div class="stat-value">${schedules.count}</div>
|
||||
<div class="stat-sub">Cron jobs registered</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Skills</div>
|
||||
<div class="stat-value">${skills.count}</div>
|
||||
<div class="stat-sub">Agent capabilities</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Features grid
|
||||
const fg = document.getElementById('features-grid');
|
||||
fg.innerHTML = features.features.map(f => `
|
||||
<div class="feature-card">
|
||||
<div class="feature-name">${f.name}</div>
|
||||
<div class="feature-desc">${f.description}</div>
|
||||
<div class="feature-routes">${f.routes.length} routes
|
||||
${f.routes.slice(0, 3).map(r => '<span class="feature-route">' + r + '</span>').join('')}
|
||||
${f.routes.length > 3 ? '<span class="feature-route">... +' + (f.routes.length - 3) + ' more</span>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Channels table
|
||||
document.getElementById('channel-count').textContent = channels.count;
|
||||
document.querySelector('#channels-table tbody').innerHTML = channels.channels.map(c => `
|
||||
<tr>
|
||||
<td><strong>${c.name}</strong></td>
|
||||
<td><span class="badge badge-platform">${c.platform}</span></td>
|
||||
<td>${c.running ? '<span class="badge badge-running">● running</span>' : '<span class="badge badge-offline">● stopped</span>'}</td>
|
||||
<td>${c.enabled ? '<span class="badge badge-enabled">enabled</span>' : '<span class="badge badge-disabled">disabled</span>'}</td>
|
||||
<td>${timeAgo(c.last_activity)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
// Nodes table
|
||||
document.getElementById('node-count').textContent = nodes.count;
|
||||
document.querySelector('#nodes-table tbody').innerHTML = nodes.nodes.map(n => `
|
||||
<tr>
|
||||
<td><strong>${n.name}</strong></td>
|
||||
<td><code>${n.host}</code></td>
|
||||
<td>${n.platform}</td>
|
||||
<td>${n.status === 'online' ? '<span class="badge badge-online">● online</span>' : '<span class="badge badge-offline">● offline</span>'}</td>
|
||||
<td>${(n.agents || []).map(a => '<span class="badge badge-platform">' + a + '</span> ').join('')}</td>
|
||||
<td><span class="badge">${n.approval_policy}</span></td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
// Instances table
|
||||
document.getElementById('instance-count').textContent = instances.count;
|
||||
document.querySelector('#instances-table tbody').innerHTML = instances.instances.map(i => `
|
||||
<tr>
|
||||
<td><code>${i.id}</code></td>
|
||||
<td>${i.host}</td>
|
||||
<td>${i.platform}</td>
|
||||
<td><span class="badge badge-${i.mode === 'worker' ? 'running' : 'enabled'}">${i.mode}</span></td>
|
||||
<td>${(i.roles || []).join(', ')}</td>
|
||||
<td>${timeAgo(i.last_seen)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
// Schedules table
|
||||
document.getElementById('schedule-count').textContent = schedules.count;
|
||||
document.querySelector('#schedules-table tbody').innerHTML = schedules.schedules.map(s => `
|
||||
<tr>
|
||||
<td><strong>${s.name}</strong></td>
|
||||
<td><code>${s.schedule}</code></td>
|
||||
<td>${s.agent_id || '—'}</td>
|
||||
<td>${s.enabled ? '<span class="badge badge-enabled">enabled</span>' : '<span class="badge badge-disabled">disabled</span>'}</td>
|
||||
<td>${s.run_count || 0}</td>
|
||||
<td>${timeAgo(s.last_run)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
// Skills table
|
||||
document.getElementById('skill-count').textContent = skills.count;
|
||||
document.querySelector('#skills-table tbody').innerHTML = skills.skills.map(s => `
|
||||
<tr>
|
||||
<td><strong>${s.name}</strong></td>
|
||||
<td><code>${s.id}</code></td>
|
||||
<td>${s.enabled ? '<span class="badge badge-enabled">enabled</span>' : '<span class="badge badge-disabled">disabled</span>'}</td>
|
||||
<td><code>${JSON.stringify(s.config || {})}</code></td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Auto-refresh every 10 seconds
|
||||
loadOverview();
|
||||
setInterval(loadOverview, 10000);
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
|
||||
# ── Entry point ──────────────────────────────────────────────────────
|
||||
|
||||
seed_demo_data()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = create_app()
|
||||
|
||||
# Add the dashboard HTML landing page
|
||||
async def dashboard_landing(request):
|
||||
return HTMLResponse(DASHBOARD_HTML)
|
||||
|
||||
app.routes.insert(0, Route("/", dashboard_landing, methods=["GET"]))
|
||||
|
||||
print("✅ Agent Dashboard at http://localhost:8082")
|
||||
print(" JSON API: http://localhost:8082/api/features")
|
||||
uvicorn.run(app, host="0.0.0.0", port=8082, log_level="info")
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Real Agent Dashboard — PraisonAI agents + PraisonAIUI dashboard.
|
||||
|
||||
Unlike 12-agent-dashboard (mock data), this runs REAL PraisonAI agents.
|
||||
This example uses secure component-based rendering with REAL PraisonAI agents.
|
||||
Uses PraisonAIUI's native dashboard with @reply, @page, register_agent()
|
||||
— all backed by actual LLM calls.
|
||||
|
||||
@@ -18,15 +18,19 @@ Run:
|
||||
aiui run app.py --style dashboard
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import praisonaiui as aiui
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '_shared'))
|
||||
from console import safe_print as print
|
||||
from stream_bridge import StreamBridge
|
||||
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.streaming.events import StreamEventType
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"praisonaiagents required: pip install praisonaiagents"
|
||||
@@ -102,31 +106,21 @@ async def on_message(message: str):
|
||||
|
||||
await aiui.think("Agent thinking...")
|
||||
start = time.time()
|
||||
token_queue = asyncio.Queue()
|
||||
|
||||
def on_stream(event):
|
||||
if event.type == StreamEventType.DELTA_TEXT and event.content:
|
||||
token_queue.put_nowait(event.content)
|
||||
elif event.type == StreamEventType.FIRST_TOKEN and event.content:
|
||||
token_queue.put_nowait(event.content)
|
||||
elif event.type == StreamEventType.STREAM_END:
|
||||
token_queue.put_nowait(None)
|
||||
|
||||
agent.stream_emitter.add_callback(on_stream)
|
||||
# Set up thread-safe streaming bridge
|
||||
bridge = StreamBridge()
|
||||
callback = bridge.emitter_callback()
|
||||
agent.stream_emitter.add_callback(callback)
|
||||
full = []
|
||||
|
||||
try:
|
||||
task = asyncio.get_event_loop().run_in_executor(
|
||||
None, lambda: agent.chat(str(message), stream=True)
|
||||
# Run agent.chat concurrently with token consumption
|
||||
task = asyncio.create_task(
|
||||
asyncio.to_thread(lambda: agent.chat(str(message), stream=True))
|
||||
)
|
||||
|
||||
while True:
|
||||
try:
|
||||
tok = await asyncio.wait_for(token_queue.get(), timeout=60)
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
if tok is None:
|
||||
break
|
||||
# Consume tokens as they arrive and stream to UI
|
||||
async for tok in bridge.consume():
|
||||
full.append(tok)
|
||||
await aiui.stream_token(tok)
|
||||
|
||||
@@ -139,7 +133,8 @@ async def on_message(message: str):
|
||||
await aiui.say(f"❌ Error: {e}")
|
||||
|
||||
finally:
|
||||
agent.stream_emitter.remove_callback(on_stream)
|
||||
agent.stream_emitter.remove_callback(callback)
|
||||
bridge.cancel()
|
||||
|
||||
|
||||
# ── @page — live metrics from real calls ─────────────────────────────
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
"""Example 14: All Features Demo — Comprehensive API test suite.
|
||||
|
||||
Demonstrates ALL 16 PraisonAIUI features with real API calls.
|
||||
Run: PYTHONPATH=src python examples/python/14-all-features/app.py
|
||||
Run: aiui run app.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add paths for imports
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "src"))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
import sys
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
from praisonaiui.server import create_app
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from _shared.console import safe_print as print
|
||||
|
||||
|
||||
def test_all_features():
|
||||
"""Test all 16 feature APIs with real data."""
|
||||
|
||||
@@ -85,5 +85,7 @@ async def metrics_page():
|
||||
app = create_app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8082)
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
uvicorn.run(app, host=host, port=8082)
|
||||
@@ -44,15 +44,18 @@ import sys
|
||||
# ── Import praisonaiui ──────────────────────────────────────
|
||||
import praisonaiui as aiui
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from _shared.console import icon, safe_print
|
||||
|
||||
# ── Check gateway availability ──────────────────────────────
|
||||
try:
|
||||
from praisonaiui.integration import AIUIGateway
|
||||
from praisonaiagents import Agent
|
||||
GATEWAY_OK = True
|
||||
except ImportError as e:
|
||||
print(f"⚠️ Gateway dependencies missing: {e}")
|
||||
print(" Install with: pip install praisonai praisonaiagents")
|
||||
print(" Falling back to standalone mode.")
|
||||
safe_print(f"{icon('⚠️', '[WARN]')} Gateway dependencies missing: {e}")
|
||||
safe_print(" Install with: pip install praisonai praisonaiagents")
|
||||
safe_print(" Falling back to standalone mode.")
|
||||
GATEWAY_OK = False
|
||||
|
||||
# ── Set dashboard style ─────────────────────────────────────
|
||||
@@ -149,18 +152,19 @@ def _register_agents_in_dashboard():
|
||||
"model": agent_def["model"],
|
||||
"icon": agent_def["icon"],
|
||||
})
|
||||
print(f" ✓ Dashboard: {agent_def['icon']} {agent_def['name']}")
|
||||
safe_print(f" {icon('✓', '[OK]')} Dashboard: {agent_def['name']}")
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────
|
||||
async def main():
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
if GATEWAY_OK:
|
||||
print("🚀 Starting with Gateway mode (full execution + streaming)")
|
||||
print()
|
||||
safe_print(f"{icon('🚀', '[START]')} Starting with Gateway mode (full execution + streaming)")
|
||||
safe_print()
|
||||
|
||||
# Create the gateway
|
||||
gateway = AIUIGateway(
|
||||
host="0.0.0.0",
|
||||
host=host,
|
||||
port=8083,
|
||||
)
|
||||
|
||||
@@ -173,29 +177,29 @@ async def main():
|
||||
self_reflect=False,
|
||||
)
|
||||
gateway.register_agent(agent, agent_id=agent_def["agent_id"])
|
||||
print(f" ✓ Gateway: {agent_def['icon']} {agent_def['name']} ({agent_def['model']})")
|
||||
safe_print(f" {icon('✓', '[OK]')} Gateway: {agent_def['name']} ({agent_def['model']})")
|
||||
|
||||
# ── Register agents in dashboard CRUD storage ───────
|
||||
_register_agents_in_dashboard()
|
||||
|
||||
print()
|
||||
print(f" Dashboard: http://localhost:8083")
|
||||
print(f" WebSocket: ws://localhost:8083/ws")
|
||||
print(f" Inspector: http://localhost:8083 → 🔍 Feature Inspector")
|
||||
print()
|
||||
safe_print()
|
||||
safe_print(" Dashboard: http://localhost:8083")
|
||||
safe_print(" WebSocket: ws://localhost:8083/ws")
|
||||
safe_print(f" Inspector: http://localhost:8083 {icon('→', '->')} {icon('🔍', '')} Feature Inspector")
|
||||
safe_print()
|
||||
|
||||
# Start the gateway — this runs the full server
|
||||
await gateway.start()
|
||||
else:
|
||||
# Fallback to standalone mode (same as example 15)
|
||||
print("📦 Starting in standalone mode (CRUD only, no agent execution)")
|
||||
safe_print(f"{icon('📦', '[INFO]')} Starting in standalone mode (CRUD only, no agent execution)")
|
||||
from praisonaiui.server import create_app
|
||||
import uvicorn
|
||||
|
||||
_register_agents_in_dashboard()
|
||||
|
||||
app = create_app()
|
||||
config = uvicorn.Config(app, host="0.0.0.0", port=8083, log_level="info")
|
||||
config = uvicorn.Config(app, host=host, port=8083, log_level="info")
|
||||
server = uvicorn.Server(config)
|
||||
await server.serve()
|
||||
|
||||
|
||||
@@ -26,15 +26,18 @@ import sys
|
||||
|
||||
import praisonaiui as aiui
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from _shared.console import icon, safe_print
|
||||
|
||||
# ── Check gateway availability ──────────────────────────────
|
||||
try:
|
||||
from praisonaiui.integration import AIUIGateway
|
||||
from praisonaiagents import Agent
|
||||
GATEWAY_OK = True
|
||||
except ImportError as e:
|
||||
print(f"⚠️ Gateway dependencies not found: {e}")
|
||||
print(" Install with: pip install praisonai praisonaiagents")
|
||||
print(" Falling back to standalone mode (REST APIs only).\n")
|
||||
safe_print(f"{icon('⚠️', '[WARN]')} Gateway dependencies not found: {e}")
|
||||
safe_print(" Install with: pip install praisonai praisonaiagents")
|
||||
safe_print(" Falling back to standalone mode (REST APIs only).\n")
|
||||
GATEWAY_OK = False
|
||||
|
||||
aiui.set_style("dashboard")
|
||||
@@ -115,19 +118,20 @@ def _register_agents_in_dashboard():
|
||||
"model": agent_def["model"],
|
||||
"icon": agent_def["icon"],
|
||||
})
|
||||
print(f" ✓ Agent: {agent_def['icon']} {agent_def['name']}")
|
||||
safe_print(f" {icon('✓', '[OK]')} Agent: {agent_def['name']}")
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────
|
||||
|
||||
async def main():
|
||||
port = int(os.getenv("PORT", "8082"))
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
|
||||
if GATEWAY_OK:
|
||||
print("🚀 Starting with Gateway mode (full execution + streaming)")
|
||||
print()
|
||||
safe_print(f"{icon('🚀', '[START]')} Starting with Gateway mode (full execution + streaming)")
|
||||
safe_print()
|
||||
|
||||
gateway = AIUIGateway(host="0.0.0.0", port=port)
|
||||
gateway = AIUIGateway(host=host, port=port)
|
||||
|
||||
# Register agents with gateway for real execution
|
||||
for agent_def in AGENTS:
|
||||
@@ -138,34 +142,34 @@ async def main():
|
||||
reflection=False,
|
||||
)
|
||||
gateway.register_agent(agent, agent_id=agent_def["agent_id"])
|
||||
print(f" ✓ Gateway: {agent_def['icon']} {agent_def['name']} ({agent_def['model']})")
|
||||
safe_print(f" {icon('✓', '[OK]')} Gateway: {agent_def['name']} ({agent_def['model']})")
|
||||
|
||||
# Also register in dashboard CRUD
|
||||
_register_agents_in_dashboard()
|
||||
|
||||
print()
|
||||
print(f" Dashboard: http://localhost:{port}")
|
||||
print(f" Explorer: http://localhost:{port} → 🔬 Feature Explorer")
|
||||
print(f" WebSocket: ws://localhost:{port}/ws")
|
||||
print()
|
||||
safe_print()
|
||||
safe_print(f" Dashboard: http://localhost:{port}")
|
||||
safe_print(f" Explorer: http://localhost:{port} {icon('→', '->')} {icon('🔬', '')} Feature Explorer")
|
||||
safe_print(f" WebSocket: ws://localhost:{port}/ws")
|
||||
safe_print()
|
||||
|
||||
await gateway.start()
|
||||
else:
|
||||
# Standalone: REST APIs only, no agent execution
|
||||
print("📦 Starting in standalone mode (REST APIs only, no agent execution)")
|
||||
safe_print(f"{icon('📦', '[INFO]')} Starting in standalone mode (REST APIs only, no agent execution)")
|
||||
from praisonaiui.server import create_app
|
||||
import uvicorn
|
||||
|
||||
_register_agents_in_dashboard()
|
||||
|
||||
app = create_app()
|
||||
print()
|
||||
print(f" Dashboard: http://localhost:{port}")
|
||||
print(f" Explorer: http://localhost:{port} → 🔬 Feature Explorer")
|
||||
print(f" Note: POST endpoints may fail without gateway")
|
||||
print()
|
||||
safe_print()
|
||||
safe_print(f" Dashboard: http://localhost:{port}")
|
||||
safe_print(f" Explorer: http://localhost:{port} {icon('→', '->')} {icon('🔬', '')} Feature Explorer")
|
||||
safe_print(" Note: POST endpoints may fail without gateway")
|
||||
safe_print()
|
||||
|
||||
config = uvicorn.Config(app, host="0.0.0.0", port=port, log_level="info")
|
||||
config = uvicorn.Config(app, host=host, port=port, log_level="info")
|
||||
server = uvicorn.Server(config)
|
||||
await server.serve()
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""PraisonAIUI — Full Chat Example (with Gateway + Page Config).
|
||||
|
||||
Dashboard with curated sidebar pages and a custom Feature Explorer:
|
||||
- Uses ``aiui.set_pages()`` to show only essential built-in pages
|
||||
Clean chat-focused dashboard with curated sidebar pages:
|
||||
- Uses ``aiui.set_pages()`` to show only essential pages (chat, sessions, agents, usage, config)
|
||||
- Hides debug/admin pages for a streamlined experience
|
||||
- Adds a custom "Feature Explorer" page via ``@aiui.page()``
|
||||
- Registers three agents: Researcher, Writer, Coder
|
||||
|
||||
Compare with 17-three-column-demo which shows ALL available pages.
|
||||
|
||||
Modes:
|
||||
1. Gateway mode (recommended) — starts real agent execution + WebSocket
|
||||
Requires: pip install praisonai praisonaiagents
|
||||
@@ -26,19 +29,32 @@ import sys
|
||||
|
||||
import praisonaiui as aiui
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from _shared.console import icon, safe_print
|
||||
|
||||
# ── Check gateway availability ──────────────────────────────
|
||||
try:
|
||||
from praisonaiui.integration import AIUIGateway
|
||||
from praisonaiagents import Agent
|
||||
|
||||
from praisonaiui.integration import AIUIGateway
|
||||
GATEWAY_OK = True
|
||||
except ImportError as e:
|
||||
print(f"⚠️ Gateway dependencies not found: {e}")
|
||||
print(" Install with: pip install praisonai praisonaiagents")
|
||||
print(" Falling back to standalone mode (REST APIs only).\n")
|
||||
safe_print(f"{icon('⚠️', '[WARN]')} Gateway dependencies not found: {e}")
|
||||
safe_print(" Install with: pip install praisonai praisonaiagents")
|
||||
safe_print(" Falling back to standalone mode (REST APIs only).\n")
|
||||
GATEWAY_OK = False
|
||||
|
||||
aiui.set_style("dashboard")
|
||||
|
||||
# ── Page curation ───────────────────────────────────────────
|
||||
aiui.set_pages([
|
||||
"chat", # Main chat interface
|
||||
"sessions", # Session management
|
||||
"agents", # Agent configuration
|
||||
"usage", # Usage metrics
|
||||
"config", # Configuration settings
|
||||
])
|
||||
|
||||
|
||||
# ── Agent definitions ───────────────────────────────────────
|
||||
AGENTS = [
|
||||
@@ -115,19 +131,20 @@ def _register_agents_in_dashboard():
|
||||
"model": agent_def["model"],
|
||||
"icon": agent_def["icon"],
|
||||
})
|
||||
print(f" ✓ Agent: {agent_def['icon']} {agent_def['name']}")
|
||||
safe_print(f" {icon('✓', '[OK]')} Agent: {agent_def['name']}")
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────
|
||||
|
||||
async def main():
|
||||
port = int(os.getenv("PORT", "8082"))
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
|
||||
if GATEWAY_OK:
|
||||
print("🚀 Starting with Gateway mode (full execution + streaming)")
|
||||
print()
|
||||
safe_print(f"{icon('🚀', '[START]')} Starting with Gateway mode (full execution + streaming)")
|
||||
safe_print()
|
||||
|
||||
gateway = AIUIGateway(host="0.0.0.0", port=port)
|
||||
gateway = AIUIGateway(host=host, port=port)
|
||||
|
||||
# Register agents with gateway for real execution
|
||||
for agent_def in AGENTS:
|
||||
@@ -138,34 +155,35 @@ async def main():
|
||||
reflection=False,
|
||||
)
|
||||
gateway.register_agent(agent, agent_id=agent_def["agent_id"])
|
||||
print(f" ✓ Gateway: {agent_def['icon']} {agent_def['name']} ({agent_def['model']})")
|
||||
safe_print(f" {icon('✓', '[OK]')} Gateway: {agent_def['name']} ({agent_def['model']})")
|
||||
|
||||
# Also register in dashboard CRUD
|
||||
_register_agents_in_dashboard()
|
||||
|
||||
print()
|
||||
print(f" Dashboard: http://localhost:{port}")
|
||||
print(f" Explorer: http://localhost:{port} → 🔬 Feature Explorer")
|
||||
print(f" WebSocket: ws://localhost:{port}/ws")
|
||||
print()
|
||||
safe_print()
|
||||
safe_print(f" Dashboard: http://localhost:{port}")
|
||||
safe_print(f" Explorer: http://localhost:{port} {icon('→', '->')} {icon('🔬', '')} Feature Explorer")
|
||||
safe_print(f" WebSocket: ws://localhost:{port}/ws")
|
||||
safe_print()
|
||||
|
||||
await gateway.start()
|
||||
else:
|
||||
# Standalone: REST APIs only, no agent execution
|
||||
print("📦 Starting in standalone mode (REST APIs only, no agent execution)")
|
||||
from praisonaiui.server import create_app
|
||||
safe_print(f"{icon('📦', '[INFO]')} Starting in standalone mode (REST APIs only, no agent execution)")
|
||||
import uvicorn
|
||||
|
||||
from praisonaiui.server import create_app
|
||||
|
||||
_register_agents_in_dashboard()
|
||||
|
||||
app = create_app()
|
||||
print()
|
||||
print(f" Dashboard: http://localhost:{port}")
|
||||
print(f" Explorer: http://localhost:{port} → 🔬 Feature Explorer")
|
||||
print(f" Note: POST endpoints may fail without gateway")
|
||||
print()
|
||||
safe_print()
|
||||
safe_print(f" Dashboard: http://localhost:{port}")
|
||||
safe_print(f" Explorer: http://localhost:{port} {icon('→', '->')} {icon('🔬', '')} Feature Explorer")
|
||||
safe_print(" Note: POST endpoints may fail without gateway")
|
||||
safe_print()
|
||||
|
||||
config = uvicorn.Config(app, host="0.0.0.0", port=port, log_level="info")
|
||||
config = uvicorn.Config(app, host=host, port=port, log_level="info")
|
||||
server = uvicorn.Server(config)
|
||||
await server.serve()
|
||||
|
||||
|
||||
@@ -32,6 +32,9 @@ import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from _shared.console import icon, safe_print
|
||||
|
||||
# ── Check prerequisites ─────────────────────────────────────────────
|
||||
|
||||
try:
|
||||
@@ -40,8 +43,8 @@ try:
|
||||
SDK_OK = True
|
||||
except ImportError:
|
||||
SDK_OK = False
|
||||
print("⚠️ Missing dependencies. Install with:")
|
||||
print(" pip install praisonai[email] praisonaiagents")
|
||||
safe_print(f"{icon('⚠️', '[WARN]')} Missing dependencies. Install with:")
|
||||
safe_print(" pip install praisonai[email] praisonaiagents")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -68,8 +71,8 @@ agent = Agent(
|
||||
async def run_simple():
|
||||
"""Start email bot with automatic env var resolution."""
|
||||
bot = Bot("email", agent=agent)
|
||||
print(f"📧 Starting email bot for: {os.getenv('EMAIL_ADDRESS', '(not set)')}")
|
||||
print(" Listening for new emails... (Ctrl+C to stop)")
|
||||
safe_print(f"{icon('📧', '[EMAIL]')} Starting email bot for: {os.getenv('EMAIL_ADDRESS', '(not set)')}")
|
||||
safe_print(" Listening for new emails... (Ctrl+C to stop)")
|
||||
await bot.start()
|
||||
|
||||
|
||||
@@ -96,9 +99,9 @@ async def run_with_handlers():
|
||||
@bot.on_message
|
||||
async def log_email(message):
|
||||
subject = message.metadata.get("subject", "(no subject)")
|
||||
print(f"📨 From: {message.sender.username}")
|
||||
print(f" Subject: {subject}")
|
||||
print(f" Preview: {message.text[:100]}...")
|
||||
safe_print(f"{icon('📨', '[MSG]')} From: {message.sender.username}")
|
||||
safe_print(f" Subject: {subject}")
|
||||
safe_print(f" Preview: {message.text[:100]}...")
|
||||
|
||||
# Custom command: emails with subject starting with "/status"
|
||||
@bot.on_command("status")
|
||||
@@ -118,11 +121,11 @@ async def run_with_handlers():
|
||||
reply_to=message.message_id,
|
||||
)
|
||||
|
||||
print(f"📧 Starting email bot with custom handlers")
|
||||
print(f" Email: {bot._email_address}")
|
||||
print(f" Poll interval: {config.polling_interval}s")
|
||||
print(f" Allowed senders: {'all' if not config.allowed_users else config.allowed_users}")
|
||||
print(" Listening... (Ctrl+C to stop)")
|
||||
safe_print(f"{icon('📧', '[EMAIL]')} Starting email bot with custom handlers")
|
||||
safe_print(f" Email: {bot._email_address}")
|
||||
safe_print(f" Poll interval: {config.polling_interval}s")
|
||||
safe_print(f" Allowed senders: {'all' if not config.allowed_users else config.allowed_users}")
|
||||
safe_print(" Listening... (Ctrl+C to stop)")
|
||||
await bot.start()
|
||||
|
||||
|
||||
@@ -131,17 +134,17 @@ async def run_with_handlers():
|
||||
async def run_probe():
|
||||
"""Test email server connectivity."""
|
||||
bot = Bot("email", agent=agent)
|
||||
print("🔍 Testing email server connectivity...")
|
||||
safe_print(f"{icon('🔍', '[PROBE]')} Testing email server connectivity...")
|
||||
result = await bot.probe()
|
||||
|
||||
print(f"\n Connection: {'✅ OK' if result.ok else '❌ FAILED'}")
|
||||
print(f" Platform: {result.platform}")
|
||||
safe_print(f"\n Connection: {icon('✅', '[OK]') + ' OK' if result.ok else icon('❌', '[FAIL]') + ' FAILED'}")
|
||||
safe_print(f" Platform: {result.platform}")
|
||||
if result.bot_username:
|
||||
print(f" Email: {result.bot_username}")
|
||||
safe_print(f" Email: {result.bot_username}")
|
||||
if result.elapsed_ms:
|
||||
print(f" Latency: {result.elapsed_ms:.0f}ms")
|
||||
safe_print(f" Latency: {result.elapsed_ms:.0f}ms")
|
||||
if result.error:
|
||||
print(f" Error: {result.error}")
|
||||
safe_print(f" Error: {result.error}")
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────
|
||||
@@ -31,7 +31,7 @@ Requires:
|
||||
export EMAIL_APP_PASSWORD=your_app_password
|
||||
|
||||
Run:
|
||||
python app.py
|
||||
aiui run app.py
|
||||
# Dashboard at http://localhost:8084
|
||||
"""
|
||||
|
||||
@@ -42,7 +42,9 @@ import sys
|
||||
# ── Imports ─────────────────────────────────────────────────────────
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "src"))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from _shared.console import icon, safe_print
|
||||
from praisonaiui.server import create_app
|
||||
import uvicorn
|
||||
|
||||
@@ -85,16 +87,17 @@ def seed_email_channel():
|
||||
"email_address": os.getenv("EMAIL_ADDRESS", "[email protected]"),
|
||||
"imap_server": os.getenv("EMAIL_IMAP_SERVER", "imap.gmail.com"),
|
||||
"smtp_server": os.getenv("EMAIL_SMTP_SERVER", "smtp.gmail.com"),
|
||||
"app_password_ref": "env:EMAIL_APP_PASSWORD",
|
||||
"polling_interval": 30,
|
||||
},
|
||||
})
|
||||
if r.status_code in (200, 201):
|
||||
channel_id = r.json().get("id", "unknown")
|
||||
print(f" ✅ Email channel registered: {channel_id}")
|
||||
safe_print(f" {icon('✅', '[OK]')} Email channel registered: {channel_id}")
|
||||
else:
|
||||
print(f" ⚠️ Channel registration: {r.status_code} — {r.json()}")
|
||||
safe_print(f" {icon('⚠️', '[WARN]')} Channel registration: {r.status_code} - {r.json()}")
|
||||
else:
|
||||
print(" ✓ Email channel already exists")
|
||||
safe_print(f" {icon('✓', '[OK]')} Email channel already exists")
|
||||
|
||||
# Also seed other channels for comparison in dashboard
|
||||
for name, platform in [("Discord #general", "discord"), ("Telegram Support", "telegram")]:
|
||||
@@ -105,7 +108,7 @@ def seed_email_channel():
|
||||
"platform": platform,
|
||||
"config": {},
|
||||
})
|
||||
print(f" ✓ Seeded {platform} channel")
|
||||
safe_print(f" {icon('✓', '[OK]')} Seeded {platform} channel")
|
||||
|
||||
|
||||
# ── Verify email platform support ──────────────────────────────────
|
||||
@@ -121,8 +124,8 @@ def verify_email_support():
|
||||
if r.status_code == 200:
|
||||
platforms = r.json().get("platforms", [])
|
||||
email_supported = "email" in platforms
|
||||
print(f" Supported platforms: {platforms}")
|
||||
print(f" Email supported: {'✅ Yes' if email_supported else '❌ No — add email to SUPPORTED_PLATFORMS'}")
|
||||
safe_print(f" Supported platforms: {platforms}")
|
||||
safe_print(f" Email supported: {icon('✅', '[OK]') + ' Yes' if email_supported else icon('❌', '[X]') + ' No - add email to SUPPORTED_PLATFORMS'}")
|
||||
return email_supported
|
||||
return False
|
||||
|
||||
@@ -136,50 +139,51 @@ def test_channel_api():
|
||||
app = create_app()
|
||||
client = TestClient(app)
|
||||
|
||||
print("\n📋 Channel API Test")
|
||||
print("=" * 40)
|
||||
safe_print(f"\n{icon('📋', '[TEST]')} Channel API Test")
|
||||
safe_print("=" * 40)
|
||||
|
||||
# List channels
|
||||
r = client.get("/api/channels")
|
||||
channels = r.json().get("channels", [])
|
||||
print(f" GET /api/channels: {len(channels)} channels")
|
||||
safe_print(f" GET /api/channels: {len(channels)} channels")
|
||||
for ch in channels:
|
||||
icon = "📧" if ch.get("platform") == "email" else "💬"
|
||||
print(f" {icon} {ch.get('name', '?')} ({ch.get('platform', '?')})")
|
||||
ch_icon = icon("📧", "[email]") if ch.get("platform") == "email" else icon("💬", "[chat]")
|
||||
safe_print(f" {ch_icon} {ch.get('name', '?')} ({ch.get('platform', '?')})")
|
||||
|
||||
# Get email channel status
|
||||
email_ch = next((ch for ch in channels if ch.get("platform") == "email"), None)
|
||||
if email_ch:
|
||||
ch_id = email_ch.get("id")
|
||||
r = client.get(f"/api/channels/{ch_id}/status")
|
||||
print(f" GET /api/channels/{ch_id}/status: {r.json()}")
|
||||
safe_print(f" GET /api/channels/{ch_id}/status: {r.json()}")
|
||||
|
||||
print()
|
||||
safe_print()
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("📧 PraisonAIUI — Email Channel Integration")
|
||||
print("=" * 50)
|
||||
print()
|
||||
safe_print(f"{icon('📧', '[EMAIL]')} PraisonAIUI - Email Channel Integration")
|
||||
safe_print("=" * 50)
|
||||
safe_print()
|
||||
|
||||
if "--test" in sys.argv:
|
||||
# Test mode: verify APIs + seed data
|
||||
print("🔍 Verifying email platform support...")
|
||||
safe_print(f"{icon('🔍', '[VERIFY]')} Verifying email platform support...")
|
||||
verify_email_support()
|
||||
print()
|
||||
print("🌱 Seeding email channel...")
|
||||
safe_print()
|
||||
safe_print(f"{icon('🌱', '[SEED]')} Seeding email channel...")
|
||||
seed_email_channel()
|
||||
test_channel_api()
|
||||
else:
|
||||
# Run the dashboard
|
||||
print("🌱 Seeding channels...")
|
||||
safe_print(f"{icon('🌱', '[SEED]')} Seeding channels...")
|
||||
seed_email_channel()
|
||||
print()
|
||||
safe_print()
|
||||
|
||||
app = create_app()
|
||||
print(f"✅ Dashboard at http://localhost:8084")
|
||||
print(f" Channels: http://localhost:8084/api/channels")
|
||||
print(f" Platforms: http://localhost:8084/api/channels/platforms")
|
||||
uvicorn.run(app, host="0.0.0.0", port=8084, log_level="info")
|
||||
safe_print(f"{icon('✅', '[OK]')} Dashboard at http://localhost:8084")
|
||||
safe_print(" Channels: http://localhost:8084/api/channels")
|
||||
safe_print(" Platforms: http://localhost:8084/api/channels/platforms")
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
uvicorn.run(app, host=host, port=8084, log_level="info")
|
||||
@@ -33,6 +33,9 @@ import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from _shared.console import icon, safe_print
|
||||
|
||||
# ── Check prerequisites ─────────────────────────────────────────────
|
||||
|
||||
try:
|
||||
@@ -41,8 +44,8 @@ try:
|
||||
SDK_OK = True
|
||||
except ImportError:
|
||||
SDK_OK = False
|
||||
print("⚠️ Missing dependencies. Install with:")
|
||||
print(" pip install praisonai agentmail praisonaiagents")
|
||||
safe_print(f"{icon('⚠️', '[WARN]')} Missing dependencies. Install with:")
|
||||
safe_print(" pip install praisonai agentmail praisonaiagents")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -69,9 +72,9 @@ agent = Agent(
|
||||
async def run_simple():
|
||||
"""Start agentmail bot with automatic env var resolution."""
|
||||
bot = Bot("agentmail", agent=agent)
|
||||
print(f"📩 Starting AgentMail bot")
|
||||
print(f" API key: {'✅ set' if os.getenv('AGENTMAIL_API_KEY') else '❌ not set'}")
|
||||
print(" Listening for new emails... (Ctrl+C to stop)")
|
||||
safe_print(f"{icon('📩', '[MAIL]')} Starting AgentMail bot")
|
||||
safe_print(f" API key: {icon('✅', '[OK]') + ' set' if os.getenv('AGENTMAIL_API_KEY') else icon('❌', '[X]') + ' not set'}")
|
||||
safe_print(" Listening for new emails... (Ctrl+C to stop)")
|
||||
await bot.start()
|
||||
|
||||
|
||||
@@ -99,9 +102,9 @@ async def run_with_handlers():
|
||||
@bot.on_message
|
||||
async def log_email(message):
|
||||
subject = message.metadata.get("subject", "(no subject)")
|
||||
print(f"📨 From: {message.sender.username}")
|
||||
print(f" Subject: {subject}")
|
||||
print(f" Preview: {message.text[:100]}...")
|
||||
safe_print(f"{icon('📨', '[MSG]')} From: {message.sender.username}")
|
||||
safe_print(f" Subject: {subject}")
|
||||
safe_print(f" Preview: {message.text[:100]}...")
|
||||
|
||||
# Custom command: emails with subject starting with "/status"
|
||||
@bot.on_command("status")
|
||||
@@ -121,10 +124,10 @@ async def run_with_handlers():
|
||||
reply_to=message.message_id,
|
||||
)
|
||||
|
||||
print(f"📩 Starting AgentMail bot with custom handlers")
|
||||
print(f" Email: {bot.email_address or '(will be assigned on start)'}")
|
||||
print(f" Poll interval: {config.polling_interval}s")
|
||||
print(" Listening... (Ctrl+C to stop)")
|
||||
safe_print(f"{icon('📩', '[MAIL]')} Starting AgentMail bot with custom handlers")
|
||||
safe_print(f" Email: {bot.email_address or '(will be assigned on start)'}")
|
||||
safe_print(f" Poll interval: {config.polling_interval}s")
|
||||
safe_print(" Listening... (Ctrl+C to stop)")
|
||||
await bot.start()
|
||||
|
||||
|
||||
@@ -136,30 +139,30 @@ async def run_inbox_demo():
|
||||
token=os.environ.get("AGENTMAIL_API_KEY", ""),
|
||||
)
|
||||
|
||||
print("📩 AgentMail Inbox Lifecycle Demo")
|
||||
print("=" * 50)
|
||||
safe_print(f"{icon('📩', '[MAIL]')} AgentMail Inbox Lifecycle Demo")
|
||||
safe_print("=" * 50)
|
||||
|
||||
# Create inbox
|
||||
print("\n1. Creating inbox...")
|
||||
safe_print("\n1. Creating inbox...")
|
||||
inbox = await bot.create_inbox(domain=os.getenv("AGENTMAIL_DOMAIN"))
|
||||
print(f" ✅ Created: {inbox.get('email_address', 'unknown')}")
|
||||
print(f" ID: {inbox.get('id', 'unknown')}")
|
||||
safe_print(f" {icon('✅', '[OK]')} Created: {inbox.get('email_address', 'unknown')}")
|
||||
safe_print(f" ID: {inbox.get('id', 'unknown')}")
|
||||
|
||||
# List inboxes
|
||||
print("\n2. Listing inboxes...")
|
||||
safe_print("\n2. Listing inboxes...")
|
||||
inboxes = await bot.list_inboxes()
|
||||
print(f" Found: {len(inboxes)} inbox(es)")
|
||||
safe_print(f" Found: {len(inboxes)} inbox(es)")
|
||||
for ib in inboxes:
|
||||
print(f" 📧 {ib.get('email_address', '?')} (id: {ib.get('id', '?')})")
|
||||
safe_print(f" {icon('📧', '[EMAIL]')} {ib.get('email_address', '?')} (id: {ib.get('id', '?')})")
|
||||
|
||||
# Delete inbox
|
||||
inbox_id = inbox.get("id", "")
|
||||
if inbox_id:
|
||||
print(f"\n3. Deleting inbox {inbox_id}...")
|
||||
safe_print(f"\n3. Deleting inbox {inbox_id}...")
|
||||
deleted = await bot.delete_inbox(inbox_id)
|
||||
print(f" {'✅ Deleted' if deleted else '❌ Failed to delete'}")
|
||||
safe_print(f" {icon('✅', '[OK]') + ' Deleted' if deleted else icon('❌', '[X]') + ' Failed to delete'}")
|
||||
|
||||
print("\n✅ Inbox lifecycle complete")
|
||||
safe_print(f"\n{icon('✅', '[OK]')} Inbox lifecycle complete")
|
||||
|
||||
|
||||
# ── Probe: Test connectivity ────────────────────────────────────────
|
||||
@@ -167,17 +170,17 @@ async def run_inbox_demo():
|
||||
async def run_probe():
|
||||
"""Test AgentMail API connectivity."""
|
||||
bot = Bot("agentmail", agent=agent)
|
||||
print("🔍 Testing AgentMail API connectivity...")
|
||||
safe_print(f"{icon('🔍', '[PROBE]')} Testing AgentMail API connectivity...")
|
||||
result = await bot.probe()
|
||||
|
||||
print(f"\n Connection: {'✅ OK' if result.ok else '❌ FAILED'}")
|
||||
print(f" Platform: {result.platform}")
|
||||
safe_print(f"\n Connection: {icon('✅', '[OK]') + ' OK' if result.ok else icon('❌', '[FAIL]') + ' FAILED'}")
|
||||
safe_print(f" Platform: {result.platform}")
|
||||
if result.bot_username:
|
||||
print(f" Email: {result.bot_username}")
|
||||
safe_print(f" Email: {result.bot_username}")
|
||||
if result.elapsed_ms:
|
||||
print(f" Latency: {result.elapsed_ms:.0f}ms")
|
||||
safe_print(f" Latency: {result.elapsed_ms:.0f}ms")
|
||||
if result.error:
|
||||
print(f" Error: {result.error}")
|
||||
safe_print(f" Error: {result.error}")
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────
|
||||
+30
-27
@@ -40,7 +40,9 @@ import sys
|
||||
# ── Imports ─────────────────────────────────────────────────────────
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "src"))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from _shared.console import icon, safe_print
|
||||
from praisonaiui.server import create_app
|
||||
import uvicorn
|
||||
|
||||
@@ -80,7 +82,7 @@ def seed_agentmail_channel():
|
||||
"name": "Agent Inbox",
|
||||
"platform": "agentmail",
|
||||
"config": {
|
||||
"api_key": os.getenv("AGENTMAIL_API_KEY", ""),
|
||||
"api_key_ref": "env:AGENTMAIL_API_KEY",
|
||||
"domain": os.getenv("AGENTMAIL_DOMAIN", ""),
|
||||
"inbox_id": os.getenv("AGENTMAIL_INBOX_ID", ""),
|
||||
"polling_interval": 15,
|
||||
@@ -88,11 +90,11 @@ def seed_agentmail_channel():
|
||||
})
|
||||
if r.status_code in (200, 201):
|
||||
channel_id = r.json().get("id", "unknown")
|
||||
print(f" ✅ AgentMail channel registered: {channel_id}")
|
||||
safe_print(f" {icon('✅', '[OK]')} AgentMail channel registered: {channel_id}")
|
||||
else:
|
||||
print(f" ⚠️ Channel registration: {r.status_code} — {r.json()}")
|
||||
safe_print(f" {icon('⚠️', '[WARN]')} Channel registration: {r.status_code} - {r.json()}")
|
||||
else:
|
||||
print(" ✓ AgentMail channel already exists")
|
||||
safe_print(f" {icon('✓', '[OK]')} AgentMail channel already exists")
|
||||
|
||||
# Also seed email + telegram for comparison in dashboard
|
||||
for name, platform in [("Support Email", "email"), ("Telegram Support", "telegram")]:
|
||||
@@ -103,7 +105,7 @@ def seed_agentmail_channel():
|
||||
"platform": platform,
|
||||
"config": {},
|
||||
})
|
||||
print(f" ✓ Seeded {platform} channel")
|
||||
safe_print(f" {icon('✓', '[OK]')} Seeded {platform} channel")
|
||||
|
||||
|
||||
# ── Verify agentmail platform support ──────────────────────────────
|
||||
@@ -120,9 +122,9 @@ def verify_agentmail_support():
|
||||
platforms = r.json().get("platforms", [])
|
||||
am_supported = "agentmail" in platforms
|
||||
email_supported = "email" in platforms
|
||||
print(f" Supported platforms: {platforms}")
|
||||
print(f" email supported: {'✅ Yes' if email_supported else '❌ No'}")
|
||||
print(f" agentmail supported: {'✅ Yes' if am_supported else '❌ No'}")
|
||||
safe_print(f" Supported platforms: {platforms}")
|
||||
safe_print(f" email supported: {icon('✅', '[OK]') + ' Yes' if email_supported else icon('❌', '[X]') + ' No'}")
|
||||
safe_print(f" agentmail supported: {icon('✅', '[OK]') + ' Yes' if am_supported else icon('❌', '[X]') + ' No'}")
|
||||
return am_supported
|
||||
return False
|
||||
|
||||
@@ -136,50 +138,51 @@ def test_channel_api():
|
||||
app = create_app()
|
||||
client = TestClient(app)
|
||||
|
||||
print("\n📋 Channel API Test")
|
||||
print("=" * 40)
|
||||
safe_print(f"\n{icon('📋', '[TEST]')} Channel API Test")
|
||||
safe_print("=" * 40)
|
||||
|
||||
# List channels
|
||||
r = client.get("/api/channels")
|
||||
channels = r.json().get("channels", [])
|
||||
print(f" GET /api/channels: {len(channels)} channels")
|
||||
safe_print(f" GET /api/channels: {len(channels)} channels")
|
||||
for ch in channels:
|
||||
icon = {"email": "📧", "agentmail": "📩"}.get(ch.get("platform", ""), "💬")
|
||||
print(f" {icon} {ch.get('name', '?')} ({ch.get('platform', '?')})")
|
||||
ch_icon = {"email": icon("📧", "[email]"), "agentmail": icon("📩", "[mail]")}.get(ch.get("platform", ""), icon("💬", "[chat]"))
|
||||
safe_print(f" {ch_icon} {ch.get('name', '?')} ({ch.get('platform', '?')})")
|
||||
|
||||
# Get agentmail channel status
|
||||
am_ch = next((ch for ch in channels if ch.get("platform") == "agentmail"), None)
|
||||
if am_ch:
|
||||
ch_id = am_ch.get("id")
|
||||
r = client.get(f"/api/channels/{ch_id}/status")
|
||||
print(f" GET /api/channels/{ch_id}/status: {r.json()}")
|
||||
safe_print(f" GET /api/channels/{ch_id}/status: {r.json()}")
|
||||
|
||||
print()
|
||||
safe_print()
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("📩 PraisonAIUI — AgentMail Channel Integration")
|
||||
print("=" * 50)
|
||||
print()
|
||||
safe_print(f"{icon('📩', '[MAIL]')} PraisonAIUI - AgentMail Channel Integration")
|
||||
safe_print("=" * 50)
|
||||
safe_print()
|
||||
|
||||
if "--test" in sys.argv:
|
||||
# Test mode: verify APIs + seed data
|
||||
print("🔍 Verifying agentmail platform support...")
|
||||
safe_print(f"{icon('🔍', '[VERIFY]')} Verifying agentmail platform support...")
|
||||
verify_agentmail_support()
|
||||
print()
|
||||
print("🌱 Seeding agentmail channel...")
|
||||
safe_print()
|
||||
safe_print(f"{icon('🌱', '[SEED]')} Seeding agentmail channel...")
|
||||
seed_agentmail_channel()
|
||||
test_channel_api()
|
||||
else:
|
||||
# Run the dashboard
|
||||
print("🌱 Seeding channels...")
|
||||
safe_print(f"{icon('🌱', '[SEED]')} Seeding channels...")
|
||||
seed_agentmail_channel()
|
||||
print()
|
||||
safe_print()
|
||||
|
||||
app = create_app()
|
||||
print(f"✅ Dashboard at http://localhost:8084")
|
||||
print(f" Channels: http://localhost:8084/api/channels")
|
||||
print(f" Platforms: http://localhost:8084/api/channels/platforms")
|
||||
uvicorn.run(app, host="0.0.0.0", port=8084, log_level="info")
|
||||
safe_print(f"{icon('✅', '[OK]')} Dashboard at http://localhost:8084")
|
||||
safe_print(" Channels: http://localhost:8084/api/channels")
|
||||
safe_print(" Platforms: http://localhost:8084/api/channels/platforms")
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
uvicorn.run(app, host=host, port=8084, log_level="info")
|
||||
+3
-1
@@ -227,5 +227,7 @@ async def on_welcome():
|
||||
app = create_app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8083)
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
uvicorn.run(app, host=host, port=8083)
|
||||
File renamed without changes.
@@ -19,6 +19,22 @@ import os
|
||||
|
||||
import praisonaiui as aiui
|
||||
|
||||
try:
|
||||
from openai import AsyncOpenAI
|
||||
except ImportError:
|
||||
AsyncOpenAI = None
|
||||
|
||||
# Reuse client to prevent resource churn
|
||||
_client = None
|
||||
|
||||
def _get_client():
|
||||
global _client
|
||||
if _client is None and AsyncOpenAI is not None:
|
||||
api_key = os.getenv("OPENAI_API_KEY")
|
||||
if api_key:
|
||||
_client = AsyncOpenAI(api_key=api_key)
|
||||
return _client
|
||||
|
||||
# ── Dashboard style, but no sidebar navigation ─────────────
|
||||
aiui.set_style("dashboard")
|
||||
aiui.set_dashboard(sidebar=False, page_header=False)
|
||||
@@ -49,18 +65,14 @@ async def on_message(message: str):
|
||||
"""Stream a response from OpenAI."""
|
||||
await aiui.think("Thinking...")
|
||||
|
||||
try:
|
||||
from openai import AsyncOpenAI
|
||||
except ImportError:
|
||||
await aiui.say("❌ Please install openai: `pip install openai`")
|
||||
client = _get_client()
|
||||
if client is None:
|
||||
if AsyncOpenAI is None:
|
||||
await aiui.say("❌ Please install openai: `pip install openai`")
|
||||
else:
|
||||
await aiui.say("❌ Please set OPENAI_API_KEY environment variable.")
|
||||
return
|
||||
|
||||
api_key = os.getenv("OPENAI_API_KEY")
|
||||
if not api_key:
|
||||
await aiui.say("❌ Please set OPENAI_API_KEY environment variable.")
|
||||
return
|
||||
|
||||
client = AsyncOpenAI(api_key=api_key)
|
||||
stream = await client.chat.completions.create(
|
||||
model=os.getenv("PRAISONAI_MODEL", "gpt-4o-mini"),
|
||||
messages=[
|
||||
+22
-10
@@ -18,6 +18,22 @@ import os
|
||||
|
||||
import praisonaiui as aiui
|
||||
|
||||
try:
|
||||
from openai import AsyncOpenAI
|
||||
except ImportError:
|
||||
AsyncOpenAI = None
|
||||
|
||||
# Reuse client to prevent resource churn
|
||||
_client = None
|
||||
|
||||
def _get_client():
|
||||
global _client
|
||||
if _client is None and AsyncOpenAI is not None:
|
||||
api_key = os.getenv("OPENAI_API_KEY")
|
||||
if api_key:
|
||||
_client = AsyncOpenAI(api_key=api_key)
|
||||
return _client
|
||||
|
||||
# ── Register custom teal/cyan theme ──────────────────────────────────
|
||||
aiui.register_theme("teal-dark", {
|
||||
"accent": "#14b8a6", # teal-500
|
||||
@@ -58,18 +74,14 @@ async def on_message(message: str):
|
||||
"""Stream a response from OpenAI."""
|
||||
await aiui.think("Thinking...")
|
||||
|
||||
try:
|
||||
from openai import AsyncOpenAI
|
||||
except ImportError:
|
||||
await aiui.say("❌ Please install openai: `pip install openai`")
|
||||
client = _get_client()
|
||||
if client is None:
|
||||
if AsyncOpenAI is None:
|
||||
await aiui.say("❌ Please install openai: `pip install openai`")
|
||||
else:
|
||||
await aiui.say("❌ Please set OPENAI_API_KEY environment variable.")
|
||||
return
|
||||
|
||||
api_key = os.getenv("OPENAI_API_KEY")
|
||||
if not api_key:
|
||||
await aiui.say("❌ Please set OPENAI_API_KEY environment variable.")
|
||||
return
|
||||
|
||||
client = AsyncOpenAI(api_key=api_key)
|
||||
stream = await client.chat.completions.create(
|
||||
model=os.getenv("PRAISONAI_MODEL", "gpt-4o-mini"),
|
||||
messages=[
|
||||
File renamed without changes.
+5
-8
@@ -36,17 +36,14 @@ python app.py
|
||||
## Testing the custom component + custom view
|
||||
|
||||
The `timeline` component and the `custom-view` page are client-side extensions.
|
||||
Load `plugin.js` into the page:
|
||||
`app.py` loads `plugin.js` automatically via `aiui.set_custom_js()` — no DevTools paste required.
|
||||
|
||||
1. Open http://localhost:8082 in the browser
|
||||
2. Open DevTools → Console
|
||||
3. Paste the full contents of `plugin.js` and press Enter
|
||||
4. Navigate to **Custom Component** → the timeline now renders as a styled list
|
||||
5. Navigate to **Client-Only View** → a JS-rendered clock appears
|
||||
2. Navigate to **Custom Component** → the timeline renders as a styled list
|
||||
3. Navigate to **Client-Only View** → a JS-rendered clock appears
|
||||
|
||||
> **Gap**: There is currently no Python API to inject client-side JS from
|
||||
> `app.py`. A proposed `aiui.set_custom_js(path)` would close this gap —
|
||||
> see the `docs/features/how-to-add-a-feature.md` guide.
|
||||
> **Note:** `aiui.set_custom_js(path)` injects client-side JS from a file path at page load.
|
||||
> See `docs/features/how-to-add-a-feature.md` for the extension API contract.
|
||||
|
||||
## Testing the form action
|
||||
|
||||
+3
-1
@@ -214,7 +214,9 @@ async def custom_view_fallback():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
import uvicorn
|
||||
from praisonaiui.server import create_app
|
||||
app = create_app()
|
||||
uvicorn.run(app, host="0.0.0.0", port=8082)
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
uvicorn.run(app, host=host, port=8082)
|
||||
+51
-14
@@ -27,14 +27,25 @@
|
||||
'display:flex;gap:16px;align-items:center;padding:12px;' +
|
||||
'background:var(--db-card-bg, #1f2937);border-radius:8px;' +
|
||||
'border-left:4px solid var(--db-accent, #818cf8)';
|
||||
row.innerHTML =
|
||||
'<div style="font-size:24px">' + (ev.icon || '•') + '</div>' +
|
||||
'<div style="flex:1">' +
|
||||
'<div style="font-weight:600;color:var(--db-text, #fff)">' +
|
||||
(ev.label || '') + '</div>' +
|
||||
'<div style="font-size:12px;color:var(--db-text-muted, #94a3b8);' +
|
||||
'margin-top:4px">' + (ev.time || '') + '</div>' +
|
||||
'</div>';
|
||||
const iconDiv = document.createElement('div');
|
||||
iconDiv.style.cssText = 'font-size:24px';
|
||||
iconDiv.textContent = ev.icon || '•';
|
||||
|
||||
const contentDiv = document.createElement('div');
|
||||
contentDiv.style.cssText = 'flex:1';
|
||||
|
||||
const labelDiv = document.createElement('div');
|
||||
labelDiv.style.cssText = 'font-weight:600;color:var(--db-text, #fff)';
|
||||
labelDiv.textContent = ev.label || '';
|
||||
|
||||
const timeDiv = document.createElement('div');
|
||||
timeDiv.style.cssText = 'font-size:12px;color:var(--db-text-muted, #94a3b8);margin-top:4px';
|
||||
timeDiv.textContent = ev.time || '';
|
||||
|
||||
contentDiv.appendChild(labelDiv);
|
||||
contentDiv.appendChild(timeDiv);
|
||||
row.appendChild(iconDiv);
|
||||
row.appendChild(contentDiv);
|
||||
el.appendChild(row);
|
||||
});
|
||||
return el;
|
||||
@@ -47,12 +58,38 @@
|
||||
const box = document.createElement('div');
|
||||
box.style.cssText = 'padding:24px;background:var(--db-card-bg, #1f2937);' +
|
||||
'border-radius:12px;color:var(--db-text, #fff)';
|
||||
box.innerHTML =
|
||||
'<h2 style="margin:0 0 16px 0">⚡ Client-Only View</h2>' +
|
||||
'<p>This entire page is rendered by JavaScript in the browser — ' +
|
||||
'it does <b>not</b> call <code>/api/pages/custom-view/data</code>.</p>' +
|
||||
'<p>Registered via <code>window.aiui.registerView(\'custom-view\', ...)</code>.</p>' +
|
||||
'<p>Current time: <span id="aiui-clock"></span></p>';
|
||||
const h2 = document.createElement('h2');
|
||||
h2.style.cssText = 'margin:0 0 16px 0';
|
||||
h2.textContent = '⚡ Client-Only View';
|
||||
|
||||
const p1 = document.createElement('p');
|
||||
p1.textContent = 'This entire page is rendered by JavaScript in the browser — it does ';
|
||||
const strongCode = document.createElement('strong');
|
||||
strongCode.textContent = 'not';
|
||||
p1.appendChild(strongCode);
|
||||
p1.appendChild(document.createTextNode(' call '));
|
||||
const code1 = document.createElement('code');
|
||||
code1.textContent = '/api/pages/custom-view/data';
|
||||
p1.appendChild(code1);
|
||||
p1.appendChild(document.createTextNode('.'));
|
||||
|
||||
const p2 = document.createElement('p');
|
||||
p2.textContent = 'Registered via ';
|
||||
const code2 = document.createElement('code');
|
||||
code2.textContent = 'window.aiui.registerView(\'custom-view\', ...)';
|
||||
p2.appendChild(code2);
|
||||
p2.appendChild(document.createTextNode('.'));
|
||||
|
||||
const p3 = document.createElement('p');
|
||||
p3.textContent = 'Current time: ';
|
||||
const clockSpan = document.createElement('span');
|
||||
clockSpan.id = 'aiui-clock';
|
||||
p3.appendChild(clockSpan);
|
||||
|
||||
box.appendChild(h2);
|
||||
box.appendChild(p1);
|
||||
box.appendChild(p2);
|
||||
box.appendChild(p3);
|
||||
container.appendChild(box);
|
||||
const clock = box.querySelector('#aiui-clock');
|
||||
const tick = function () { clock.textContent = new Date().toLocaleTimeString(); };
|
||||
Loaded 100 of 591 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user