mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 05:56:17 -10:00
Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1e879d981 | ||
|
|
6b565c7210 | ||
|
|
e67db90331 | ||
|
|
1887ae6ae4 | ||
|
|
ae919445a0 | ||
|
|
984ddf74d2 | ||
|
|
5dd3cd81e4 | ||
|
|
a433c483c1 | ||
|
|
814a2e1636 | ||
|
|
01e6618833 | ||
|
|
b3a5341785 | ||
|
|
06ac1372eb | ||
|
|
7a320bde1b | ||
|
|
2162b61629 | ||
|
|
e14b5817b1 | ||
|
|
77d7d4241a | ||
|
|
86063a3ed0 | ||
|
|
75fb931a23 | ||
|
|
7d098f4097 | ||
|
|
c752fe0f65 | ||
|
|
c4384fd818 | ||
|
|
c8600029b3 | ||
|
|
f56cbf6243 | ||
|
|
4fe69554fc | ||
|
|
9546cefbde | ||
|
|
6a8c634031 | ||
|
|
4df8c29f1d | ||
|
|
e53df0ca78 | ||
|
|
3d9c8da13d | ||
|
|
b46f4450a0 | ||
|
|
f4ec043989 | ||
|
|
df02e0c15b | ||
|
|
66acd35496 | ||
|
|
8a483a62dd | ||
|
|
d418dcf443 | ||
|
|
c467643c15 | ||
|
|
8caa8d51d0 | ||
|
|
541ada0399 | ||
|
|
fb28f2a2ee | ||
|
|
ac0a4f32a8 | ||
|
|
3a0fa72c3d | ||
|
|
f38aa27345 | ||
|
|
39615089ba | ||
|
|
77b67271ec | ||
|
|
7099e9f2ce | ||
|
|
3f02c32740 | ||
|
|
a9dfc97e8c | ||
|
|
5429a287e7 | ||
|
|
a4167b1477 | ||
|
|
0923aac53a |
No files matched your search
@@ -1,8 +1,7 @@
|
||||
name: Auto Issue Comment
|
||||
|
||||
# Optional: automatically escalate every new issue to Claude for a full
|
||||
# investigation + draft fix. Disabled by default (only workflow_dispatch).
|
||||
# To enable auto-triage on every new issue, uncomment the `issues:` trigger.
|
||||
# Automatically escalate every new issue to Claude for a full investigation
|
||||
# + draft fix. Manual re-trigger: workflow_dispatch with issue_number.
|
||||
#
|
||||
# Note: `@claude` mentions only trigger claude.yml when the commenter is a
|
||||
# HUMAN user (or github-actions[bot] via PAT). This workflow posts the
|
||||
@@ -14,9 +13,9 @@ on:
|
||||
issue_number:
|
||||
description: "Issue number to triage with Claude"
|
||||
required: true
|
||||
type: number
|
||||
# issues:
|
||||
# types: [opened]
|
||||
type: string
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
# Escalate an issue to full Claude implementation by adding the `claude`
|
||||
@@ -29,6 +28,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
actions: write
|
||||
steps:
|
||||
- name: Resolve issue number
|
||||
id: resolve
|
||||
@@ -52,18 +52,17 @@ jobs:
|
||||
});
|
||||
|
||||
const labels = (issue.labels || []).map(l => typeof l === 'string' ? l : l.name);
|
||||
if (labels.includes('claude')) {
|
||||
console.log(`Issue #${issueNumber} already has 'claude' label — skipping.`);
|
||||
return;
|
||||
if (!labels.includes('claude')) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
labels: ['claude'],
|
||||
});
|
||||
} else {
|
||||
console.log(`Issue #${issueNumber} already has 'claude' label.`);
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
labels: ['claude'],
|
||||
});
|
||||
|
||||
const body = [
|
||||
'🤖 Auto-triaging with Claude.',
|
||||
'',
|
||||
@@ -84,3 +83,12 @@ jobs:
|
||||
issue_number: issueNumber,
|
||||
body,
|
||||
});
|
||||
|
||||
await github.rest.actions.createWorkflowDispatch({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: 'claude.yml',
|
||||
ref: 'main',
|
||||
inputs: { issue_number: String(issueNumber) },
|
||||
});
|
||||
console.log(`Dispatched Claude Assistant for issue #${issueNumber}`);
|
||||
@@ -16,11 +16,24 @@ on:
|
||||
types: [submitted]
|
||||
pull_request:
|
||||
types: [opened]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number to trigger Claude final review on'
|
||||
required: true
|
||||
type: number
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
MAX_POLL_ATTEMPTS: 20
|
||||
POLL_DELAY_MS: 30000
|
||||
CLAUDE_TRIGGER_LOGINS: '["MervinPraison","github-actions[bot]"]'
|
||||
# GH_TOKEN (user PAT) posts as MervinPraison for @copilot; fallback to github.token for @claude
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
|
||||
jobs:
|
||||
# ================================================================
|
||||
@@ -43,7 +56,7 @@ jobs:
|
||||
id: trigger
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: context.issue.number,
|
||||
@@ -86,7 +99,7 @@ jobs:
|
||||
id: trigger
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: context.issue.number,
|
||||
@@ -134,12 +147,14 @@ jobs:
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: context.issue.number,
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
@@ -148,23 +163,25 @@ jobs:
|
||||
);
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
core.setOutput('comments_count', comments.data.length.toString());
|
||||
|
||||
// Collect all bot reviewer comments for context
|
||||
const botReviews = comments.data.filter(c =>
|
||||
['coderabbitai[bot]', 'qodo-code-review[bot]', 'gemini-code-assist[bot]',
|
||||
'copilot-swe-agent', 'Copilot', 'greptile-apps[bot]'].some(bot =>
|
||||
|
||||
const botReviews = comments.data.filter(c =>
|
||||
['coderabbitai[bot]', 'qodo-code-review[bot]', 'gemini-code-assist[bot]',
|
||||
'copilot-swe-agent', 'Copilot', 'greptile-apps[bot]'].some(bot =>
|
||||
c.user.login.toLowerCase().includes(bot.toLowerCase())
|
||||
)
|
||||
);
|
||||
core.setOutput('review_count', botReviews.length.toString());
|
||||
|
||||
|
||||
- name: Aggregate reviews and trigger Claude
|
||||
if: steps.check_claude.outputs.already_triggered != 'true'
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const comments = await github.rest.issues.listComments({issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, per_page: 100});
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const comments = await github.rest.issues.listComments({issue_number: pr, owner: context.repo.owner, repo: context.repo.repo, per_page: 100});
|
||||
const reviews = {coderabbit: [], qodo: [], gemini: [], copilot: [], greptile: [], others: []};
|
||||
comments.data.forEach(c => {
|
||||
const login = c.user.login.toLowerCase();
|
||||
@@ -183,14 +200,14 @@ jobs:
|
||||
if (reviews.copilot.length) summaryParts.push('Copilot: ' + reviews.copilot.length + ' comments');
|
||||
if (reviews.greptile.length) summaryParts.push('Greptile: ' + reviews.greptile.length + ' comments');
|
||||
const reviewSummary = summaryParts.length ? summaryParts.join(' | ') : 'No bot reviews detected';
|
||||
const claudeBody = '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAIUI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on PraisonAIUI (src/praisonaiui, tests, docs). Do not expand scope into praisonaiagents or the monorepo unless the issue explicitly requires it. ' + reviewSummary + '.\n\n' +
|
||||
'Review Context: ' + comments.data.length + ' total comments, ' +
|
||||
const claudeBody = '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAIUI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on PraisonAIUI (src/praisonaiui, src/frontend, tests, docs). Read ALL prior reviewer comments. ' + reviewSummary + '.\n\n' +
|
||||
'Review Context: ' + comments.data.length + ' total comments, ' +
|
||||
(reviews.coderabbit.length + reviews.qodo.length + reviews.gemini.length + reviews.copilot.length + reviews.greptile.length) + ' bot reviews.\n\n' +
|
||||
'Phase 1: Review per AGENTS.md principles (protocol-driven, backward compatible, no perf impact).\n' +
|
||||
'Phase 2: FIX valid issues found by prior reviewers.\n' +
|
||||
'Phase 3: Provide final verdict (approve or request changes).';
|
||||
await github.rest.issues.createComment({issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: claudeBody});
|
||||
console.log('Claude fallback triggered on PR #' + context.issue.number + ' with ' + reviewSummary);
|
||||
'**Phase 1: Review per AGENTS.md** (lazy imports, backward compat, import-time <200ms)\n' +
|
||||
'**Phase 2: FIX valid issues** found by prior reviewers — push to THIS branch\n' +
|
||||
'**Phase 3: Final verdict** — approve PR if ready, else request changes';
|
||||
await github.rest.issues.createComment({issue_number: pr, owner: context.repo.owner, repo: context.repo.repo, body: claudeBody});
|
||||
console.log('Claude fallback triggered on PR #' + pr + ' with ' + reviewSummary);
|
||||
|
||||
# ================================================================
|
||||
# Claude FINAL review — polls for Copilot's response, then triggers
|
||||
@@ -217,7 +234,7 @@ jobs:
|
||||
MAX_POLL_ATTEMPTS: ${{ env.MAX_POLL_ATTEMPTS }}
|
||||
POLL_DELAY_MS: ${{ env.POLL_DELAY_MS }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const owner = context.repo.owner;
|
||||
@@ -286,7 +303,7 @@ jobs:
|
||||
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
@@ -306,7 +323,7 @@ jobs:
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
await github.rest.issues.createComment({
|
||||
@@ -340,7 +357,7 @@ jobs:
|
||||
MAX_POLL_ATTEMPTS: ${{ env.MAX_POLL_ATTEMPTS }}
|
||||
POLL_DELAY_MS: ${{ env.POLL_DELAY_MS }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const owner = context.repo.owner;
|
||||
@@ -390,7 +407,7 @@ jobs:
|
||||
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
@@ -410,7 +427,7 @@ jobs:
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
await github.rest.issues.createComment({
|
||||
@@ -445,7 +462,7 @@ jobs:
|
||||
env:
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
@@ -462,7 +479,7 @@ jobs:
|
||||
if: steps.check_claude.outputs.already_triggered != 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
@@ -492,7 +509,7 @@ jobs:
|
||||
- name: Trigger CodeRabbit and Qodo reviews
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = context.payload.pull_request.number;
|
||||
const owner = context.repo.owner;
|
||||
@@ -522,4 +539,102 @@ jobs:
|
||||
// NOTE: @copilot is NOT triggered here.
|
||||
// It will be triggered by copilot-after-coderabbit or copilot-after-qodo
|
||||
// once those bots finish, ensuring Copilot always reviews LAST.
|
||||
console.log('Copilot will be triggered after CodeRabbit/Qodo complete.');
|
||||
console.log('Copilot will be triggered after CodeRabbit/Qodo complete.');
|
||||
|
||||
# ================================================================
|
||||
# BOT PRs: CodeRabbit → Claude directly (skip Copilot when no user PAT)
|
||||
# Copilot ignores github-actions[bot] comments; bot PRs use this path.
|
||||
# ================================================================
|
||||
claude-after-coderabbit-bot-pr:
|
||||
if: |
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
github.event.comment.user.login == 'coderabbitai[bot]' &&
|
||||
contains(github.event.comment.body, 'summarize by coderabbit')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Trigger Claude final review on bot PR
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = context.issue.number;
|
||||
const { data: pull } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr,
|
||||
});
|
||||
const author = pull.user.login;
|
||||
const isBot = author === 'github-actions[bot]' ||
|
||||
author === 'praisonai-triage-agent[bot]' ||
|
||||
pull.user.type === 'Bot';
|
||||
if (!isBot) {
|
||||
console.log('Human PR — Copilot chain handles Claude trigger');
|
||||
return;
|
||||
}
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 100,
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
if (alreadyPosted) {
|
||||
console.log('Claude already triggered on PR #' + pr);
|
||||
return;
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the FINAL architecture reviewer on this bot-authored PR. Push fixes directly to THIS branch (do NOT open a new PR). SCOPE: PraisonAIUI only (src/praisonaiui, src/frontend, tests, docs). Read ALL CodeRabbit/Qodo comments above.\n\n**Phase 1:** Review per AGENTS.md (lazy imports, backward compat, import-time <200ms)\n**Phase 2:** FIX valid reviewer issues on this branch\n**Phase 3:** Approve PR if ready, else request changes with clear action items'
|
||||
});
|
||||
console.log('Claude final review triggered on bot PR #' + pr);
|
||||
|
||||
# ================================================================
|
||||
# Manual: trigger Claude final review on any PR
|
||||
# ================================================================
|
||||
claude-manual-dispatch:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Trigger Claude final review
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ inputs.pr_number }}
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 100,
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
if (alreadyPosted) {
|
||||
console.log('Claude already triggered on PR #' + pr);
|
||||
return;
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the FINAL architecture reviewer. Push fixes directly to THIS branch. SCOPE: PraisonAIUI (src/praisonaiui, src/frontend, tests, docs). Read all prior reviewer comments.\n\n**Phase 1:** Review per AGENTS.md\n**Phase 2:** FIX valid issues on this branch\n**Phase 3:** Approve PR if ready, else request changes'
|
||||
});
|
||||
console.log('Claude manually triggered on PR #' + pr);
|
||||
@@ -28,10 +28,12 @@ jobs:
|
||||
run: ruff check src/praisonaiui
|
||||
|
||||
- name: Test with pytest
|
||||
# Integration tests are environment-sensitive (LLM API keys,
|
||||
# long-running flows). Unit tests are the contract CI guards.
|
||||
# Unit tests are the primary CI contract; integration smoke validates backends.
|
||||
run: pytest tests/unit -v --no-cov --timeout=60
|
||||
|
||||
- name: Integration smoke tests
|
||||
run: pytest tests/integration/test_agentic_roundtrip.py tests/test_feature_sdk_backends.py -q --no-cov --timeout=120
|
||||
|
||||
docs:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
|
||||
+308
-79
@@ -1,13 +1,16 @@
|
||||
name: Claude Assistant
|
||||
|
||||
# Triggers:
|
||||
# - issues:opened → lightweight triage (welcome comment + label)
|
||||
# - issues:opened → lightweight triage (labels + auto-claude for owner)
|
||||
# - issues:labeled → full implementation when `claude` label applied
|
||||
# - issues:assigned → full implementation when issue assigned to Claude
|
||||
# - issue_comment → full implementation when @claude mentioned (issues + PRs)
|
||||
# - pull_request_review → full implementation when @claude mentioned in review
|
||||
# - pull_request_review_comment → full implementation when @claude mentioned inline
|
||||
#
|
||||
# Escalate an issue with the `claude` label OR an @claude comment — not both.
|
||||
# If both fire within 3 minutes, the comment trigger is skipped (dedupe job).
|
||||
#
|
||||
# Focus: CLAUDE only. Copilot and CodeRabbit are GitHub Apps that react to PRs
|
||||
# automatically — no workflow file needed. auto-pr-comment.yml orchestrates the
|
||||
# CodeRabbit → Copilot → Claude review chain for PRs.
|
||||
@@ -21,20 +24,17 @@ on:
|
||||
types: [opened, labeled, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue_number:
|
||||
description: 'Issue number for Claude implementation'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
# ================================================================
|
||||
# Job 1 — Issue triage (runs on ANY new issue, including from external users)
|
||||
# Posts a welcome comment. Does NOT execute Claude Code.
|
||||
# To escalate to full implementation, a maintainer adds the `claude` label
|
||||
# or mentions @claude in a comment — which triggers Job 2.
|
||||
# Job 1 — Issue triage (runs on ANY new issue, including external users)
|
||||
# Posts welcome comment + triage labels. Auto-adds `claude` for owner issues.
|
||||
# ================================================================
|
||||
issue-triage:
|
||||
if: |
|
||||
@@ -46,87 +46,334 @@ jobs:
|
||||
issues: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Post welcome comment
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.CLAUDE_APP_ID }}
|
||||
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- name: Resolve GitHub token
|
||||
id: github-token
|
||||
run: |
|
||||
if [ -n "${{ steps.app-token.outputs.token }}" ]; then
|
||||
echo "token=${{ steps.app-token.outputs.token }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "token=${{ secrets.GITHUB_TOKEN }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Triage and acknowledge issue
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
github-token: ${{ steps.github-token.outputs.token }}
|
||||
script: |
|
||||
const body = [
|
||||
'👋 Thanks for opening an issue in **PraisonAIUI**.',
|
||||
'',
|
||||
'A maintainer will triage this shortly. If you would like Claude to investigate and draft a fix, a maintainer can:',
|
||||
'',
|
||||
'- Apply the `claude` label to this issue, **or**',
|
||||
'- Mention `@claude` in a comment with any additional instructions.',
|
||||
'',
|
||||
'Please make sure your issue includes:',
|
||||
'',
|
||||
'1. A clear description of the behaviour you expect vs what you see.',
|
||||
'2. Minimal reproduction steps (code snippet or `aiui.template.yaml`).',
|
||||
'3. Version: `python -c "import praisonaiui; print(praisonaiui.__version__)"`.',
|
||||
'4. Any relevant log output.',
|
||||
'',
|
||||
'See [AGENTS.md](https://github.com/MervinPraison/PraisonAIUI/blob/main/AGENTS.md) for project conventions.'
|
||||
].join('\n');
|
||||
const issue = context.payload.issue;
|
||||
const title = (issue.title || '').toLowerCase();
|
||||
const body = (issue.body || '').toLowerCase();
|
||||
const content = title + ' ' + body;
|
||||
const isOwner = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(
|
||||
issue.author_association
|
||||
);
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body
|
||||
});
|
||||
const labels = [];
|
||||
|
||||
if (content.includes('feature request') || content.includes('[feature')) {
|
||||
labels.push('enhancement');
|
||||
} else if (content.includes('bug') || content.includes('error') || content.includes('crash') || content.includes('traceback')) {
|
||||
labels.push('bug');
|
||||
} else if (content.includes('question') || content.includes('how do i') || content.includes('how to')) {
|
||||
labels.push('question');
|
||||
}
|
||||
|
||||
if (content.includes('security') || content.includes('cve') || content.includes('vulnerability')) {
|
||||
labels.push('security');
|
||||
}
|
||||
if (content.includes('performance') || content.includes('slow') || content.includes('memory leak')) {
|
||||
labels.push('performance');
|
||||
}
|
||||
if (content.includes('documentation') || content.includes('docs')) {
|
||||
labels.push('documentation');
|
||||
}
|
||||
if (content.includes('typescript') || content.includes('javascript') || content.includes('npm')) {
|
||||
labels.push('javascript');
|
||||
}
|
||||
if (content.includes('frontend') || content.includes('react') || content.includes('vite')) {
|
||||
labels.push('frontend');
|
||||
}
|
||||
if (content.includes('example') || content.includes('yaml')) {
|
||||
labels.push('examples');
|
||||
}
|
||||
|
||||
if (labels.length > 0) {
|
||||
await github.rest.issues.addLabels({
|
||||
issue_number: issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels
|
||||
});
|
||||
}
|
||||
|
||||
if (!isOwner) {
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: [
|
||||
`👋 Thanks for opening this issue, @${issue.user.login}!`,
|
||||
'',
|
||||
'A maintainer will review this shortly. In the meantime:',
|
||||
'- Make sure you\'ve included steps to reproduce (for bugs)',
|
||||
'- Check [existing issues](https://github.com/MervinPraison/PraisonAIUI/issues) for duplicates',
|
||||
'- Review [AGENTS.md](https://github.com/MervinPraison/PraisonAIUI/blob/main/AGENTS.md) for project conventions',
|
||||
'',
|
||||
'_A maintainer can trigger deeper analysis by commenting `@claude` on this issue._'
|
||||
].join('\n')
|
||||
});
|
||||
}
|
||||
|
||||
if (isOwner) {
|
||||
await github.rest.issues.addLabels({
|
||||
issue_number: issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: ['claude']
|
||||
});
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# Job 2 — Full Claude Code implementation
|
||||
# Runs when:
|
||||
# - A comment containing `@claude` is posted on an issue or PR
|
||||
# - The `claude` label is added to an issue
|
||||
# - The issue is assigned to a Claude bot
|
||||
# - A PR review or review-comment contains `@claude`
|
||||
# Triggers on: labeled `claude`, @claude comments, assignments
|
||||
#
|
||||
# Dedup: label + @claude comment on the same issue fires two events.
|
||||
# Skip the comment trigger when `claude` was labelled within 3 minutes.
|
||||
# ================================================================
|
||||
claude-response:
|
||||
env:
|
||||
ISSUE_NUMBER_RESOLVED: ${{ github.event.issue.number || github.event.pull_request.number }}
|
||||
claude-dedupe:
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(
|
||||
github.event.action != 'opened' &&
|
||||
(github.event.action != 'labeled' || github.event.label.name == 'claude') &&
|
||||
(github.event_name != 'issue_comment' || contains(github.event.comment.body, '@claude')) &&
|
||||
(
|
||||
!contains(github.actor, '[bot]') ||
|
||||
github.actor == 'github-actions[bot]' ||
|
||||
github.actor == 'praisonai-triage-agent[bot]'
|
||||
) &&
|
||||
github.actor != 'dependabot[bot]' &&
|
||||
github.actor != 'cursor[bot]' &&
|
||||
github.actor != 'renovate[bot]' &&
|
||||
(
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && github.event.action == 'labeled' && contains(toJSON(github.event.label), 'claude')) ||
|
||||
(github.event_name == 'issues' && github.event.action == 'assigned')
|
||||
github.actor != 'renovate[bot]'
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
proceed: ${{ steps.check.outputs.proceed }}
|
||||
permissions:
|
||||
issues: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Skip duplicate comment after fresh claude label
|
||||
id: check
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
if (context.eventName === 'workflow_dispatch') {
|
||||
core.setOutput('proceed', 'true');
|
||||
return;
|
||||
}
|
||||
if (context.eventName !== 'issue_comment' || context.payload.issue?.pull_request) {
|
||||
core.setOutput('proceed', 'true');
|
||||
return;
|
||||
}
|
||||
const issue = context.payload.issue;
|
||||
const hasClaude = (issue.labels || []).some(l => l.name === 'claude');
|
||||
if (!hasClaude) {
|
||||
core.setOutput('proceed', 'true');
|
||||
return;
|
||||
}
|
||||
const events = await github.rest.issues.listEvents({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
per_page: 30,
|
||||
});
|
||||
const windowMs = 3 * 60 * 1000;
|
||||
const recentLabel = events.data.find(e =>
|
||||
e.event === 'labeled' &&
|
||||
e.label?.name === 'claude' &&
|
||||
(Date.now() - new Date(e.created_at).getTime()) < windowMs
|
||||
);
|
||||
if (recentLabel) {
|
||||
core.info('Skipping: claude label was added within 3 minutes — label trigger already handles this issue.');
|
||||
core.setOutput('proceed', 'false');
|
||||
return;
|
||||
}
|
||||
core.setOutput('proceed', 'true');
|
||||
|
||||
claude-response:
|
||||
needs: claude-dedupe
|
||||
env:
|
||||
ISSUE_NUMBER_RESOLVED: ${{ github.event.issue.number || github.event.pull_request.number || inputs.issue_number }}
|
||||
if: needs.claude-dedupe.outputs.proceed == 'true'
|
||||
concurrency:
|
||||
group: claude-${{ github.event.issue.number || github.event.pull_request.number || inputs.issue_number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.CLAUDE_APP_ID }}
|
||||
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- name: Resolve GitHub token
|
||||
id: github-token
|
||||
run: |
|
||||
if [ -n "${{ steps.app-token.outputs.token }}" ]; then
|
||||
echo "token=${{ steps.app-token.outputs.token }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "token=${{ secrets.GITHUB_TOKEN }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Check for Fork PR
|
||||
id: check_fork
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.github-token.outputs.token }}
|
||||
script: |
|
||||
const isPR = context.eventName === 'pull_request' ||
|
||||
context.eventName === 'pull_request_review' ||
|
||||
context.eventName === 'pull_request_review_comment' ||
|
||||
(context.payload.issue && context.payload.issue.pull_request);
|
||||
if (isPR) {
|
||||
core.setOutput('is_pr_review', 'true');
|
||||
const prNumber = context.payload.pull_request
|
||||
? context.payload.pull_request.number
|
||||
: context.payload.issue.number;
|
||||
core.setOutput('pr_number', String(prNumber));
|
||||
const pr = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
core.setOutput('pr_branch', pr.data.head.ref);
|
||||
const headRepo = pr.data.head.repo?.full_name || context.repo.full_name;
|
||||
core.setOutput('head_repo', headRepo);
|
||||
if (headRepo !== `${context.repo.owner}/${context.repo.repo}`) {
|
||||
core.setOutput('is_fork', 'true');
|
||||
core.setOutput('clone_url', pr.data.head.repo.clone_url);
|
||||
core.setOutput('branch', pr.data.head.ref);
|
||||
return;
|
||||
}
|
||||
core.setOutput('is_fork', 'false');
|
||||
return;
|
||||
}
|
||||
core.setOutput('is_pr_review', 'false');
|
||||
core.setOutput('is_fork', 'false');
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
ref: ${{ github.event.issue.pull_request && format('refs/pull/{0}/head', github.event.issue.number) || github.event.repository.default_branch }}
|
||||
token: ${{ steps.github-token.outputs.token }}
|
||||
ref: ${{ steps.check_fork.outputs.is_pr_review == 'true' && format('refs/pull/{0}/head', steps.check_fork.outputs.pr_number) || github.ref }}
|
||||
|
||||
- name: Run Claude Code Action
|
||||
- name: Checkout PR branch for edits
|
||||
if: steps.check_fork.outputs.is_pr_review == 'true' && steps.check_fork.outputs.is_fork != 'true'
|
||||
env:
|
||||
PR_BRANCH: ${{ steps.check_fork.outputs.pr_branch }}
|
||||
run: |
|
||||
git checkout "${PR_BRANCH}"
|
||||
git config --local user.name "MervinPraison"
|
||||
git config --local user.email "[email protected]"
|
||||
echo "On branch: $(git branch --show-current)"
|
||||
|
||||
- name: Fetch PR branch and Setup Remote
|
||||
if: steps.check_fork.outputs.is_pr_review == 'true' && steps.check_fork.outputs.is_fork == 'true'
|
||||
env:
|
||||
PR_BRANCH: ${{ steps.check_fork.outputs.pr_branch }}
|
||||
run: |
|
||||
git fetch origin pull/${{ steps.check_fork.outputs.pr_number }}/head:"${PR_BRANCH}"
|
||||
git checkout "${PR_BRANCH}"
|
||||
git remote set-url origin file://$(pwd)
|
||||
|
||||
- name: Run Claude PR final review
|
||||
if: steps.check_fork.outputs.is_pr_review == 'true'
|
||||
uses: anthropics/claude-code-action@beta
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ steps.github-token.outputs.token }}
|
||||
with:
|
||||
allowed_bots: 'praisonai-triage-agent[bot]'
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
github_token: ${{ steps.github-token.outputs.token }}
|
||||
model: opus
|
||||
trigger_phrase: "@claude"
|
||||
label_trigger: "claude"
|
||||
allowed_bots: "praisonai-triage-agent[bot]"
|
||||
timeout_minutes: "30"
|
||||
direct_prompt: |
|
||||
${{ steps.check_fork.outputs.is_fork == 'true' && 'CRITICAL: FORK PR — comment only, do not push.\n\n' || 'PR on MervinPraison/PraisonAIUI — SAME REPO, NOT A FORK. You HAVE push permission to this branch. NEVER say this is a fork.\n\n' }}
|
||||
You are the Lead Engineer doing FINAL review on this open PR in MervinPraison/PraisonAIUI.
|
||||
Read ALL reviewer comments (CodeRabbit, Gemini, Qodo, Copilot, human). Follow AGENTS.md.
|
||||
SCOPE: src/praisonaiui, src/frontend, tests, docs only.
|
||||
|
||||
**Phase 1 — Review** architecture, security, tests, backward compat, import-time budget.
|
||||
**Phase 2 — FIX (mandatory)** implement every VALID issue on THIS branch. Push commits before Phase 3.
|
||||
Do NOT ask the author to fix. Do NOT open a new PR. Do NOT claim fork restrictions unless is_fork is true.
|
||||
**Phase 3 — Verdict** approve only after fixes are pushed and tests pass; else request changes with specifics.
|
||||
|
||||
git config --global user.name "MervinPraison"
|
||||
git config --global user.email "[email protected]"
|
||||
gh auth setup-git
|
||||
git status
|
||||
git branch --show-current
|
||||
allowed_tools: |
|
||||
Bash(git:*)
|
||||
Bash(python:*)
|
||||
Bash(pip:*)
|
||||
Bash(pytest:*)
|
||||
Bash(ruff:*)
|
||||
Bash(gh:*)
|
||||
Bash(npm:*)
|
||||
View
|
||||
GlobTool
|
||||
GrepTool
|
||||
BatchTool
|
||||
Edit
|
||||
Replace
|
||||
|
||||
- name: Run Claude Code Action
|
||||
if: steps.check_fork.outputs.is_pr_review != 'true'
|
||||
uses: anthropics/claude-code-action@beta
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.github-token.outputs.token }}
|
||||
with:
|
||||
allowed_bots: 'praisonai-triage-agent[bot]'
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ steps.github-token.outputs.token }}
|
||||
model: opus
|
||||
mode: ${{ github.event_name == 'workflow_dispatch' && 'agent' || 'tag' }}
|
||||
trigger_phrase: "@claude"
|
||||
label_trigger: "claude"
|
||||
timeout_minutes: "30"
|
||||
direct_prompt: |
|
||||
${{ steps.check_fork.outputs.is_fork == 'true' && 'CRITICAL: THIS IS A PULL REQUEST FROM A FORK. YOU DO NOT HAVE PUSH PERMISSIONS TO THIS REPOSITORY. ONLY READ FILES, VALIDATE CODE, AND PROVIDE YOUR COMMENTS/FEEDBACK DIRECTLY IN THIS PR VIA COMMENTS. DO NOT ATTEMPT TO PUSH OR CREATE PULL REQUESTS.\n\n' || 'NOTE: The branch is under MervinPraison/PraisonAIUI (not a fork). You are able to make modifications and push directly to this branch.\n\n' }}
|
||||
You are implementing changes in **MervinPraison/PraisonAIUI** (YAML-driven server-driven UI with agentic chat).
|
||||
Follow `AGENTS.md` at the repository root. If a conflict arises between this prompt and `AGENTS.md`, `AGENTS.md` wins.
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
STEP 0 — GIT & GITHUB CLI
|
||||
═══════════════════════════════════════════════════════════════
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git config --global user.name "MervinPraison"
|
||||
git config --global user.email "454862+MervinPraison@users.noreply.github.com"
|
||||
gh auth setup-git
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
@@ -204,14 +451,6 @@ jobs:
|
||||
# 3. Import-time invariant (MUST stay under 200 ms cold):
|
||||
python -c "import time, sys; t=time.time(); import praisonaiui; dt=(time.time()-t)*1000; heavy=[m for m in sys.modules if any(h in m for h in ['langchain','llama_index','mcp','slack','discord','botbuilder','openai.','anthropic.','mistralai','google.generativeai'])]; print(f'{dt:.1f}ms modules={len(sys.modules)} leaked={heavy}'); assert heavy == [], f'Heavy deps leaked: {heavy}'"
|
||||
|
||||
# 4. Real agentic end-to-end test (write this into tests/integration/
|
||||
# if your feature changes the agent-facing API):
|
||||
# from praisonaiagents import Agent
|
||||
# import praisonaiui as aiui
|
||||
# agent = Agent(name="demo", instructions="You are a helpful assistant")
|
||||
# result = agent.start("Say hello in one sentence")
|
||||
# print(result)
|
||||
|
||||
If anything fails, fix the root cause (never skip, xfail, or delete the test).
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
@@ -233,10 +472,6 @@ jobs:
|
||||
|
||||
<what changed in one paragraph>
|
||||
|
||||
## Before / After
|
||||
|
||||
<copy-pasteable snippet per public API change>
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
<copy from the issue; tick boxes only with commit SHA + file path evidence>
|
||||
@@ -246,12 +481,6 @@ jobs:
|
||||
\`\`\`
|
||||
<paste pytest output>
|
||||
\`\`\`
|
||||
|
||||
## Import-time proof
|
||||
|
||||
\`\`\`
|
||||
<paste the python -c import-time output>
|
||||
\`\`\`
|
||||
"
|
||||
fi
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Sync Claude Secrets from PraisonAI
|
||||
|
||||
# GitHub does not allow reading secret values back via API.
|
||||
# This workflow triggers the sync job in MervinPraison/PraisonAI, which copies:
|
||||
# CLAUDE_APP_ID, CLAUDE_APP_PRIVATE_KEY, CLAUDE_CODE_OAUTH_TOKEN, GH_TOKEN
|
||||
#
|
||||
# Requires SECRETS_ADMIN_PAT in PraisonAI (repo Settings → Secrets).
|
||||
# If sync fails, copy secrets manually:
|
||||
# PraisonAI → Settings → Secrets → Actions
|
||||
# PraisonAIUI → Settings → Secrets → Actions (same names)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
trigger-sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger PraisonAI secret sync
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh workflow run sync-secrets-to-aiui.yml \
|
||||
--repo MervinPraison/PraisonAI \
|
||||
--ref main
|
||||
echo "Triggered: https://github.com/MervinPraison/PraisonAI/actions/workflows/sync-secrets-to-aiui.yml"
|
||||
echo ""
|
||||
echo "If sync fails (401/403), refresh SECRETS_ADMIN_PAT in PraisonAI,"
|
||||
echo "or copy CLAUDE_APP_ID + CLAUDE_APP_PRIVATE_KEY + GH_TOKEN manually."
|
||||
+2
-1
@@ -55,4 +55,5 @@ aiui-output/
|
||||
_site
|
||||
|
||||
.praisonai
|
||||
tmp/
|
||||
tmp/
|
||||
*.db
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
+44
-22
@@ -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` |
|
||||
| 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](python/12-agent-dashboard/) | OpenClaw-style rendered HTML admin panel | `python app.py` |
|
||||
| 13 | [real-dashboard](python/13-real-dashboard/) | Real PraisonAI Agent + native dashboard + live metrics | `aiui run app.py --style dashboard` |
|
||||
| 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,15 +11,17 @@ Demonstrates:
|
||||
|
||||
Run:
|
||||
pip install praisonaiui
|
||||
python app.py
|
||||
aiui run app.py
|
||||
# Dashboard at http://localhost:8082
|
||||
# Set HOST=0.0.0.0 for Docker/LAN demos (defaults to 127.0.0.1)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import sys
|
||||
|
||||
import uvicorn
|
||||
|
||||
from praisonaiui.server import create_app
|
||||
import uvicorn
|
||||
|
||||
# Use shared seed data helper
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
@@ -31,4 +33,5 @@ if __name__ == "__main__":
|
||||
app = create_app()
|
||||
print("✅ Full Dashboard at http://localhost:8082")
|
||||
print(" API: http://localhost:8082/api/features")
|
||||
uvicorn.run(app, host="0.0.0.0", port=8082, log_level="info")
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
uvicorn.run(app, host=host, port=8082, log_level="info")
|
||||
@@ -171,7 +171,8 @@ def run_standalone():
|
||||
|
||||
aiui_app.routes.insert(0, Route("/", landing, methods=["GET"]))
|
||||
print("✅ UI Integration Demo at http://localhost:8082")
|
||||
uvicorn.run(aiui_app, host="0.0.0.0", port=8082, log_level="info")
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
uvicorn.run(aiui_app, host=host, port=8082, log_level="info")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"""Agent Dashboard — OpenClaw/agent-ui style admin panel.
|
||||
|
||||
⚠️ DEPRECATED: This example uses unsafe innerHTML rendering and will be removed in v0.5.0.
|
||||
For secure admin dashboards, use examples/15-dashboard-test/ or examples/13-real-dashboard/
|
||||
which use the component-based rendering model.
|
||||
|
||||
What's New (vs full-dashboard/):
|
||||
• Rendered HTML dashboard — not just JSON APIs
|
||||
• Sidebar navigation with live page switching
|
||||
@@ -8,12 +12,14 @@ What's New (vs full-dashboard/):
|
||||
• All data from PraisonAIUI feature APIs
|
||||
|
||||
Run:
|
||||
PYTHONPATH=src python app.py
|
||||
python app.py
|
||||
# Dashboard at http://localhost:8082
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
import uvicorn
|
||||
from starlette.responses import HTMLResponse
|
||||
from starlette.routing import Route
|
||||
@@ -24,7 +30,6 @@ from praisonaiui.server import create_app
|
||||
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>
|
||||
@@ -480,6 +485,13 @@ setInterval(loadOverview, 10000);
|
||||
seed_demo_data()
|
||||
|
||||
if __name__ == "__main__":
|
||||
warnings.warn(
|
||||
"examples/12-agent-dashboard uses unsafe innerHTML rendering and will be removed in v0.5.0. "
|
||||
"Use examples/15-dashboard-test/ or examples/13-real-dashboard/ for secure admin dashboards.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2
|
||||
)
|
||||
|
||||
app = create_app()
|
||||
|
||||
# Add the dashboard HTML landing page
|
||||
@@ -489,5 +501,8 @@ if __name__ == "__main__":
|
||||
app.routes.insert(0, Route("/", dashboard_landing, methods=["GET"]))
|
||||
|
||||
print("✅ Agent Dashboard at http://localhost:8082")
|
||||
print("⚠️ DEPRECATED: This example uses unsafe innerHTML rendering")
|
||||
print(" For secure dashboards, use examples/15-dashboard-test/")
|
||||
print(" JSON 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")
|
||||
@@ -18,15 +18,18 @@ Run:
|
||||
aiui run app.py --style dashboard
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import praisonaiui as aiui
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '_shared'))
|
||||
from stream_bridge import StreamBridge
|
||||
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.streaming.events import StreamEventType
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"praisonaiagents required: pip install praisonaiagents"
|
||||
@@ -102,31 +105,21 @@ async def on_message(message: str):
|
||||
|
||||
await aiui.think("Agent thinking...")
|
||||
start = time.time()
|
||||
token_queue = asyncio.Queue()
|
||||
|
||||
def on_stream(event):
|
||||
if event.type == StreamEventType.DELTA_TEXT and event.content:
|
||||
token_queue.put_nowait(event.content)
|
||||
elif event.type == StreamEventType.FIRST_TOKEN and event.content:
|
||||
token_queue.put_nowait(event.content)
|
||||
elif event.type == StreamEventType.STREAM_END:
|
||||
token_queue.put_nowait(None)
|
||||
|
||||
agent.stream_emitter.add_callback(on_stream)
|
||||
# Set up thread-safe streaming bridge
|
||||
bridge = StreamBridge()
|
||||
callback = bridge.emitter_callback()
|
||||
agent.stream_emitter.add_callback(callback)
|
||||
full = []
|
||||
|
||||
try:
|
||||
task = asyncio.get_event_loop().run_in_executor(
|
||||
None, lambda: agent.chat(str(message), stream=True)
|
||||
# Run agent.chat concurrently with token consumption
|
||||
task = asyncio.create_task(
|
||||
asyncio.to_thread(lambda: agent.chat(str(message), stream=True))
|
||||
)
|
||||
|
||||
while True:
|
||||
try:
|
||||
tok = await asyncio.wait_for(token_queue.get(), timeout=60)
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
if tok is None:
|
||||
break
|
||||
# Consume tokens as they arrive and stream to UI
|
||||
async for tok in bridge.consume():
|
||||
full.append(tok)
|
||||
await aiui.stream_token(tok)
|
||||
|
||||
@@ -139,7 +132,8 @@ async def on_message(message: str):
|
||||
await aiui.say(f"❌ Error: {e}")
|
||||
|
||||
finally:
|
||||
agent.stream_emitter.remove_callback(on_stream)
|
||||
agent.stream_emitter.remove_callback(callback)
|
||||
bridge.cancel()
|
||||
|
||||
|
||||
# ── @page — live metrics from real calls ─────────────────────────────
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
"""Example 14: All Features Demo — Comprehensive API test suite.
|
||||
|
||||
Demonstrates ALL 16 PraisonAIUI features with real API calls.
|
||||
Run: PYTHONPATH=src python examples/python/14-all-features/app.py
|
||||
Run: aiui run app.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add paths for imports
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "src"))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
from praisonaiui.server import create_app
|
||||
|
||||
|
||||
@@ -85,5 +85,7 @@ async def metrics_page():
|
||||
app = create_app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8082)
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
uvicorn.run(app, host=host, port=8082)
|
||||
@@ -154,13 +154,14 @@ def _register_agents_in_dashboard():
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────
|
||||
async def main():
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
if GATEWAY_OK:
|
||||
print("🚀 Starting with Gateway mode (full execution + streaming)")
|
||||
print()
|
||||
|
||||
# Create the gateway
|
||||
gateway = AIUIGateway(
|
||||
host="0.0.0.0",
|
||||
host=host,
|
||||
port=8083,
|
||||
)
|
||||
|
||||
@@ -195,7 +196,7 @@ async def main():
|
||||
_register_agents_in_dashboard()
|
||||
|
||||
app = create_app()
|
||||
config = uvicorn.Config(app, host="0.0.0.0", port=8083, log_level="info")
|
||||
config = uvicorn.Config(app, host=host, port=8083, log_level="info")
|
||||
server = uvicorn.Server(config)
|
||||
await server.serve()
|
||||
|
||||
|
||||
@@ -122,12 +122,13 @@ def _register_agents_in_dashboard():
|
||||
|
||||
async def main():
|
||||
port = int(os.getenv("PORT", "8082"))
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
|
||||
if GATEWAY_OK:
|
||||
print("🚀 Starting with Gateway mode (full execution + streaming)")
|
||||
print()
|
||||
|
||||
gateway = AIUIGateway(host="0.0.0.0", port=port)
|
||||
gateway = AIUIGateway(host=host, port=port)
|
||||
|
||||
# Register agents with gateway for real execution
|
||||
for agent_def in AGENTS:
|
||||
@@ -165,7 +166,7 @@ async def main():
|
||||
print(f" Note: POST endpoints may fail without gateway")
|
||||
print()
|
||||
|
||||
config = uvicorn.Config(app, host="0.0.0.0", port=port, log_level="info")
|
||||
config = uvicorn.Config(app, host=host, port=port, log_level="info")
|
||||
server = uvicorn.Server(config)
|
||||
await server.serve()
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""PraisonAIUI — Full Chat Example (with Gateway + Page Config).
|
||||
|
||||
Dashboard with curated sidebar pages and a custom Feature Explorer:
|
||||
- Uses ``aiui.set_pages()`` to show only essential built-in pages
|
||||
Clean chat-focused dashboard with curated sidebar pages:
|
||||
- Uses ``aiui.set_pages()`` to show only essential pages (chat, sessions, agents, usage, config)
|
||||
- Hides debug/admin pages for a streamlined experience
|
||||
- Adds a custom "Feature Explorer" page via ``@aiui.page()``
|
||||
- Registers three agents: Researcher, Writer, Coder
|
||||
|
||||
Compare with 17-three-column-demo which shows ALL available pages.
|
||||
|
||||
Modes:
|
||||
1. Gateway mode (recommended) — starts real agent execution + WebSocket
|
||||
Requires: pip install praisonai praisonaiagents
|
||||
@@ -22,14 +25,14 @@ Usage:
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
import praisonaiui as aiui
|
||||
|
||||
# ── Check gateway availability ──────────────────────────────
|
||||
try:
|
||||
from praisonaiui.integration import AIUIGateway
|
||||
from praisonaiagents import Agent
|
||||
|
||||
from praisonaiui.integration import AIUIGateway
|
||||
GATEWAY_OK = True
|
||||
except ImportError as e:
|
||||
print(f"⚠️ Gateway dependencies not found: {e}")
|
||||
@@ -39,6 +42,15 @@ except ImportError as e:
|
||||
|
||||
aiui.set_style("dashboard")
|
||||
|
||||
# ── Page curation ───────────────────────────────────────────
|
||||
aiui.set_pages([
|
||||
"chat", # Main chat interface
|
||||
"sessions", # Session management
|
||||
"agents", # Agent configuration
|
||||
"usage", # Usage metrics
|
||||
"config", # Configuration settings
|
||||
])
|
||||
|
||||
|
||||
# ── Agent definitions ───────────────────────────────────────
|
||||
AGENTS = [
|
||||
@@ -122,12 +134,13 @@ def _register_agents_in_dashboard():
|
||||
|
||||
async def main():
|
||||
port = int(os.getenv("PORT", "8082"))
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
|
||||
if GATEWAY_OK:
|
||||
print("🚀 Starting with Gateway mode (full execution + streaming)")
|
||||
print()
|
||||
|
||||
gateway = AIUIGateway(host="0.0.0.0", port=port)
|
||||
gateway = AIUIGateway(host=host, port=port)
|
||||
|
||||
# Register agents with gateway for real execution
|
||||
for agent_def in AGENTS:
|
||||
@@ -153,19 +166,20 @@ async def main():
|
||||
else:
|
||||
# Standalone: REST APIs only, no agent execution
|
||||
print("📦 Starting in standalone mode (REST APIs only, no agent execution)")
|
||||
from praisonaiui.server import create_app
|
||||
import uvicorn
|
||||
|
||||
from praisonaiui.server import create_app
|
||||
|
||||
_register_agents_in_dashboard()
|
||||
|
||||
app = create_app()
|
||||
print()
|
||||
print(f" Dashboard: http://localhost:{port}")
|
||||
print(f" Explorer: http://localhost:{port} → 🔬 Feature Explorer")
|
||||
print(f" Note: POST endpoints may fail without gateway")
|
||||
print(" Note: POST endpoints may fail without gateway")
|
||||
print()
|
||||
|
||||
config = uvicorn.Config(app, host="0.0.0.0", port=port, log_level="info")
|
||||
config = uvicorn.Config(app, host=host, port=port, log_level="info")
|
||||
server = uvicorn.Server(config)
|
||||
await server.serve()
|
||||
|
||||
|
||||
File renamed without changes.
@@ -31,7 +31,7 @@ Requires:
|
||||
export EMAIL_APP_PASSWORD=your_app_password
|
||||
|
||||
Run:
|
||||
python app.py
|
||||
aiui run app.py
|
||||
# Dashboard at http://localhost:8084
|
||||
"""
|
||||
|
||||
@@ -85,6 +85,7 @@ def seed_email_channel():
|
||||
"email_address": os.getenv("EMAIL_ADDRESS", "[email protected]"),
|
||||
"imap_server": os.getenv("EMAIL_IMAP_SERVER", "imap.gmail.com"),
|
||||
"smtp_server": os.getenv("EMAIL_SMTP_SERVER", "smtp.gmail.com"),
|
||||
"app_password_ref": "env:EMAIL_APP_PASSWORD",
|
||||
"polling_interval": 30,
|
||||
},
|
||||
})
|
||||
@@ -182,4 +183,5 @@ if __name__ == "__main__":
|
||||
print(f"✅ Dashboard at http://localhost:8084")
|
||||
print(f" Channels: http://localhost:8084/api/channels")
|
||||
print(f" Platforms: http://localhost:8084/api/channels/platforms")
|
||||
uvicorn.run(app, host="0.0.0.0", port=8084, log_level="info")
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
uvicorn.run(app, host=host, port=8084, log_level="info")
|
||||
File renamed without changes.
+3
-2
@@ -80,7 +80,7 @@ def seed_agentmail_channel():
|
||||
"name": "Agent Inbox",
|
||||
"platform": "agentmail",
|
||||
"config": {
|
||||
"api_key": os.getenv("AGENTMAIL_API_KEY", ""),
|
||||
"api_key_ref": "env:AGENTMAIL_API_KEY",
|
||||
"domain": os.getenv("AGENTMAIL_DOMAIN", ""),
|
||||
"inbox_id": os.getenv("AGENTMAIL_INBOX_ID", ""),
|
||||
"polling_interval": 15,
|
||||
@@ -182,4 +182,5 @@ if __name__ == "__main__":
|
||||
print(f"✅ Dashboard at http://localhost:8084")
|
||||
print(f" Channels: http://localhost:8084/api/channels")
|
||||
print(f" Platforms: http://localhost:8084/api/channels/platforms")
|
||||
uvicorn.run(app, host="0.0.0.0", port=8084, log_level="info")
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
uvicorn.run(app, host=host, port=8084, log_level="info")
|
||||
+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(); };
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Example 30 — A2UI canvas and send_a2ui_messages integration.
|
||||
|
||||
Run:
|
||||
python app.py
|
||||
# Optional full Google A2UI renderer in React shell:
|
||||
cd ../../../frontend && npm install @a2ui/react @a2ui/web_core && npm run build
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import praisonaiui as aiui
|
||||
|
||||
aiui.set_style("dashboard")
|
||||
aiui.set_branding(title="A2UI Canvas Demo", logo="🖼️")
|
||||
aiui.set_chat_preview(enabled=True, surface_id="main", width="38%")
|
||||
aiui.set_pages(["chat-canvas", "chat", "canvas", "agent-canvas"])
|
||||
|
||||
|
||||
@aiui.page("agent-canvas", title="Agent Canvas", icon="🖼️", group="A2UI")
|
||||
async def agent_canvas_page():
|
||||
return {"_surface": {"id": "main", "messages": []}}
|
||||
|
||||
|
||||
@aiui.surface_action("main")
|
||||
async def on_surface_action(action: dict):
|
||||
return {"received": action}
|
||||
|
||||
|
||||
def _build_assistant_agent():
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools.decorator import tool
|
||||
from praisonaiagents.tools.a2ui_tools import send_a2ui_messages as sdk_send_a2ui
|
||||
|
||||
from praisonaiui.a2ui_utils import coerce_a2ui_tool_messages
|
||||
|
||||
@tool
|
||||
def send_a2ui_messages(messages):
|
||||
"""
|
||||
Push A2UI v0.9 UI to the canvas. Always call this for buttons, layouts, forms.
|
||||
|
||||
Example:
|
||||
send_a2ui_messages(messages=[
|
||||
{"createSurface": {"surfaceId": "main", "catalogId": "basic"}},
|
||||
{"updateComponents": {"components": [
|
||||
{"component": "Text", "text": {"literal": "Mobile App Header"}},
|
||||
{"component": "Button", "text": {"literal": "Submit"},
|
||||
"action": {"name": "handleSubmit"}},
|
||||
]}},
|
||||
])
|
||||
"""
|
||||
coerced = coerce_a2ui_tool_messages(messages, surface_id="main")
|
||||
return sdk_send_a2ui(messages=coerced)
|
||||
|
||||
role = (
|
||||
"You are a UI builder. For ANY UI request you MUST call send_a2ui_messages "
|
||||
"before replying — never claim UI was created without a successful tool call. "
|
||||
"Use surface id 'main'. Prefer Column layouts for mobile app UIs."
|
||||
)
|
||||
try:
|
||||
from praisonaiagents.ui.a2ui.adapter import generate_a2ui_system_prompt
|
||||
|
||||
instructions = generate_a2ui_system_prompt(
|
||||
role_description=role,
|
||||
workflow_description="Call send_a2ui_messages with valid A2UI JSON, then briefly confirm.",
|
||||
ui_description="Dashboard canvas on surface main.",
|
||||
)
|
||||
except ImportError:
|
||||
instructions = role
|
||||
|
||||
return Agent(name="assistant", instructions=instructions, tools=[send_a2ui_messages])
|
||||
|
||||
|
||||
def create_app():
|
||||
try:
|
||||
agent = _build_assistant_agent()
|
||||
aiui.register_agent("assistant", agent)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
from praisonaiui.server import create_app as _create_app
|
||||
|
||||
return _create_app()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(create_app(), host="127.0.0.1", port=8099)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Image preview in chat — ImageAgent example.
|
||||
|
||||
Uses praisonaiagents ``ImageAgent`` with ``PraisonAIProvider`` so generated
|
||||
images appear inline in chat via ``MESSAGE_ELEMENT`` events.
|
||||
|
||||
Requires:
|
||||
pip install praisonaiagents litellm
|
||||
export OPENAI_API_KEY=your-key
|
||||
|
||||
Optional:
|
||||
export IMAGE_MODEL=gpt-image-2 # default; override if your project uses another image model
|
||||
|
||||
Run:
|
||||
aiui run app.py
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import praisonaiui as aiui
|
||||
from praisonaiui.providers import PraisonAIProvider
|
||||
|
||||
try:
|
||||
from praisonaiagents import ImageAgent
|
||||
except ImportError as exc:
|
||||
raise ImportError("Install praisonaiagents: pip install praisonaiagents") from exc
|
||||
|
||||
# Match other chat examples (25-clean-chat, 26-custom-theme-chat): dashboard shell
|
||||
aiui.set_style("dashboard")
|
||||
aiui.set_dashboard(sidebar=False, page_header=False)
|
||||
aiui.set_branding(title="Image Studio", logo="🖼️")
|
||||
aiui.set_theme(preset="blue", dark_mode=True, radius="lg")
|
||||
aiui.set_pages(["chat"])
|
||||
|
||||
image_agent = ImageAgent(
|
||||
name="Image Bot",
|
||||
llm=os.environ.get("IMAGE_MODEL", "gpt-image-2"),
|
||||
verbose=False,
|
||||
)
|
||||
# Legacy DALL-E params break newer OpenAI image endpoints
|
||||
image_agent.image_config.response_format = None
|
||||
image_agent.image_config.style = None
|
||||
|
||||
aiui.set_provider(PraisonAIProvider(agent=image_agent))
|
||||
|
||||
|
||||
@aiui.starters
|
||||
async def get_starters():
|
||||
return [
|
||||
{"label": "Sunset over mountains", "message": "A sunset over mountains", "icon": "🌄"},
|
||||
{"label": "Robot portrait", "message": "A friendly robot portrait", "icon": "🤖"},
|
||||
]
|
||||
Binary file not shown.
@@ -11,33 +11,42 @@ than direct store manipulation.
|
||||
|
||||
def seed_demo_data():
|
||||
"""Populate feature stores with demo data.
|
||||
|
||||
This is a no-op placeholder. Use API calls in tests to seed data,
|
||||
as feature stores may use different backends (file, DB, etc.).
|
||||
|
||||
Seeds data via HTTP API calls to ensure compatibility across
|
||||
different backend implementations (file, DB, etc.).
|
||||
"""
|
||||
pass
|
||||
try:
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from praisonaiui.server import create_app
|
||||
|
||||
app = create_app()
|
||||
with TestClient(app) as client:
|
||||
seed_via_api(client)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
def seed_via_api(client):
|
||||
"""Seed demo data via API calls (recommended approach).
|
||||
|
||||
|
||||
Args:
|
||||
client: Starlette TestClient instance
|
||||
"""
|
||||
# Channels
|
||||
client.post("/api/channels", json={
|
||||
"name": "Discord #general", "platform": "discord",
|
||||
"config": {"guild_id": "123456789"}
|
||||
"config": {"guild_id": "123456789", "bot_token_ref": "env:DISCORD_BOT_TOKEN"}
|
||||
})
|
||||
client.post("/api/channels", json={
|
||||
"name": "Telegram Support", "platform": "telegram",
|
||||
"config": {"bot_token": "***masked***"}
|
||||
"config": {"bot_token_ref": "env:TELEGRAM_BOT_TOKEN"}
|
||||
})
|
||||
client.post("/api/channels", json={
|
||||
"name": "Support Email", "platform": "email",
|
||||
"config": {"email_address": "[email protected]"}
|
||||
"config": {"email_address": "[email protected]", "app_password_ref": "env:EMAIL_APP_PASSWORD"}
|
||||
})
|
||||
|
||||
|
||||
# Agents
|
||||
client.post("/api/agents/definitions", json={
|
||||
"name": "Summarizer",
|
||||
@@ -49,21 +58,21 @@ def seed_via_api(client):
|
||||
"instructions": "You are an expert programmer.",
|
||||
"model": "gpt-4o"
|
||||
})
|
||||
|
||||
|
||||
# Schedules
|
||||
client.post("/api/schedules", json={
|
||||
"name": "Daily Report",
|
||||
"schedule": {"kind": "every", "every_seconds": 86400},
|
||||
"message": "Generate daily summary"
|
||||
})
|
||||
|
||||
|
||||
# Usage
|
||||
client.post("/api/usage/track", json={
|
||||
"model": "gpt-4o-mini",
|
||||
"input_tokens": 150,
|
||||
"output_tokens": 80
|
||||
})
|
||||
|
||||
|
||||
# Jobs
|
||||
client.post("/api/jobs", json={
|
||||
"prompt": "Test job",
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""StreamBridge — Thread-safe async-sync bridge for agent streaming.
|
||||
|
||||
Solves the concurrency architecture issue where PraisonAI agent execution
|
||||
runs on worker threads while PraisonAIUI chat runs on asyncio. Token streaming
|
||||
crosses this boundary via callbacks that must be thread-safe.
|
||||
|
||||
Usage:
|
||||
bridge = StreamBridge()
|
||||
|
||||
# In agent callback (worker thread):
|
||||
bridge.emit_token(token)
|
||||
|
||||
# In asyncio handler:
|
||||
async for token in bridge.consume():
|
||||
await aiui.stream_token(token)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from typing import Any, AsyncIterator, Callable
|
||||
|
||||
|
||||
class StreamBridge:
|
||||
"""Thread-safe bridge for streaming tokens from worker threads to asyncio."""
|
||||
|
||||
def __init__(self, loop: asyncio.AbstractEventLoop | None = None):
|
||||
"""Initialize the bridge.
|
||||
|
||||
Args:
|
||||
loop: Event loop to use. If None, uses the current running loop.
|
||||
"""
|
||||
if loop is None:
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
raise RuntimeError("No running event loop found. StreamBridge must be initialized from an async context (or pass an explicit loop).")
|
||||
self._loop = loop
|
||||
self._queue = asyncio.Queue()
|
||||
self._cancelled = False
|
||||
self._lock = threading.Lock()
|
||||
|
||||
|
||||
def emit_token(self, token: str) -> None:
|
||||
"""Emit a token from a worker thread (thread-safe).
|
||||
|
||||
Args:
|
||||
token: Token to emit, or None to signal end of stream.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._cancelled:
|
||||
return
|
||||
|
||||
# Use call_soon_threadsafe for thread-safe queue operations
|
||||
self._loop.call_soon_threadsafe(self._queue.put_nowait, token)
|
||||
|
||||
def emit_end(self) -> None:
|
||||
"""Signal end of stream (thread-safe)."""
|
||||
self.emit_token(None)
|
||||
|
||||
async def consume(self, timeout: float = 120.0) -> AsyncIterator[str]:
|
||||
"""Consume tokens asynchronously.
|
||||
|
||||
Args:
|
||||
timeout: Timeout in seconds for each token.
|
||||
|
||||
Yields:
|
||||
Tokens as they arrive. Stops when None is received (end signal).
|
||||
"""
|
||||
while not self._cancelled:
|
||||
try:
|
||||
token = await asyncio.wait_for(self._queue.get(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
|
||||
if token is None: # End of stream sentinel
|
||||
break
|
||||
|
||||
yield token
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""Cancel the bridge and stop token emission (thread-safe)."""
|
||||
with self._lock:
|
||||
self._cancelled = True
|
||||
# Signal any waiting consumers
|
||||
self._loop.call_soon_threadsafe(self._queue.put_nowait, None)
|
||||
|
||||
def emitter_callback(self) -> Callable[[Any], None]:
|
||||
"""Create a callback function for agent stream emitters.
|
||||
|
||||
Returns:
|
||||
Callback function that can be passed to agent.stream_emitter.add_callback()
|
||||
"""
|
||||
def callback(event):
|
||||
"""Handle stream events from PraisonAI agent."""
|
||||
try:
|
||||
from praisonaiagents.streaming.events import StreamEventType
|
||||
|
||||
if event.type == StreamEventType.DELTA_TEXT and event.content:
|
||||
self.emit_token(event.content)
|
||||
elif event.type == StreamEventType.FIRST_TOKEN and event.content:
|
||||
self.emit_token(event.content)
|
||||
elif event.type == StreamEventType.STREAM_END:
|
||||
self.emit_end()
|
||||
except (ImportError, AttributeError):
|
||||
# Fallback for other event types or missing dependencies
|
||||
if hasattr(event, 'content') and event.content:
|
||||
self.emit_token(event.content)
|
||||
|
||||
return callback
|
||||
|
||||
|
||||
def create_stream_bridge(loop: asyncio.AbstractEventLoop | None = None) -> StreamBridge:
|
||||
"""Create a new StreamBridge instance.
|
||||
|
||||
Args:
|
||||
loop: Event loop to use. If None, uses the current running loop.
|
||||
|
||||
Returns:
|
||||
New StreamBridge instance.
|
||||
"""
|
||||
return StreamBridge(loop)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Minimal external agent hosted on PraisonAIUI dashboard shell."""
|
||||
|
||||
import praisonaiui as aiui
|
||||
from praisonaiui.server import create_app
|
||||
|
||||
aiui.set_style("dashboard")
|
||||
aiui.set_branding(title="Acme Agents", logo="A")
|
||||
aiui.set_pages(["chat", "tasks", "sessions", "config"])
|
||||
aiui.set_dashboard(modules=["jobs"], sidebar=True, page_header=True)
|
||||
|
||||
|
||||
@aiui.page("tasks", title="Tasks", icon="📋", group="Work", order=5)
|
||||
async def tasks_page():
|
||||
return aiui.layout(
|
||||
[
|
||||
aiui.board(
|
||||
columns=[
|
||||
{
|
||||
"id": "todo",
|
||||
"title": "Todo",
|
||||
"cards": [
|
||||
aiui.card("Review PR", footer="agent-a"),
|
||||
aiui.card("Write docs", footer="agent-b"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "done",
|
||||
"title": "Done",
|
||||
"cards": [aiui.card("Modular shell", footer="shipped")],
|
||||
},
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Board from praisonai-platform issues (optional separate service)."""
|
||||
|
||||
import os
|
||||
|
||||
import httpx
|
||||
import praisonaiui as aiui
|
||||
from praisonaiui.server import create_app
|
||||
|
||||
PLATFORM = os.getenv("PRAISONAI_PLATFORM_URL", "http://127.0.0.1:8000")
|
||||
WORKSPACE_ID = os.getenv("PRAISONAI_PLATFORM_WORKSPACE_ID", "default")
|
||||
|
||||
aiui.set_style("dashboard")
|
||||
aiui.set_pages(["issues-board"])
|
||||
aiui.set_dashboard(sidebar=True)
|
||||
|
||||
|
||||
async def _fetch_issues() -> tuple[list, str | None]:
|
||||
"""Return (issues, error_message). error_message is None on success."""
|
||||
url = f"{PLATFORM.rstrip('/')}/api/v1/workspaces/{WORKSPACE_ID}/issues"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url)
|
||||
except httpx.ConnectError:
|
||||
return [], f"Cannot reach platform at {PLATFORM}"
|
||||
except httpx.TimeoutException:
|
||||
return [], f"Platform request timed out ({PLATFORM})"
|
||||
except Exception as exc:
|
||||
return [], f"Platform request failed: {exc}"
|
||||
|
||||
if response.status_code in (401, 403):
|
||||
return [], f"Platform auth failed (HTTP {response.status_code})"
|
||||
if response.status_code != 200:
|
||||
return [], f"Platform error (HTTP {response.status_code})"
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
return [], "Platform returned invalid JSON"
|
||||
|
||||
if isinstance(data, list):
|
||||
issues = data
|
||||
elif isinstance(data, dict):
|
||||
issues = data.get("issues") or data.get("items") or []
|
||||
else:
|
||||
issues = []
|
||||
return issues, None
|
||||
|
||||
|
||||
@aiui.page("issues-board", title="Issues", icon="📌", group="Work")
|
||||
async def issues_board():
|
||||
issues, error = await _fetch_issues()
|
||||
|
||||
if error:
|
||||
return aiui.layout([
|
||||
aiui.text(f"Platform: {PLATFORM} · workspace: {WORKSPACE_ID}"),
|
||||
aiui.card("Platform unavailable", footer=error),
|
||||
])
|
||||
|
||||
by: dict[str, list] = {}
|
||||
for issue in issues:
|
||||
status = (issue.get("status") or "backlog").lower().replace(" ", "_")
|
||||
by.setdefault(status, []).append(issue)
|
||||
|
||||
columns = []
|
||||
for status_id in ("backlog", "in_progress", "done"):
|
||||
cards = [aiui.card(i.get("title", "Issue"), footer=status_id) for i in by.get(status_id, [])]
|
||||
if not cards:
|
||||
cards = [aiui.card("(no issues)")]
|
||||
columns.append({
|
||||
"id": status_id,
|
||||
"title": status_id.replace("_", " ").title(),
|
||||
"cards": cards,
|
||||
})
|
||||
|
||||
summary = f"{len(issues)} issue(s)" if issues else "No issues in workspace"
|
||||
return aiui.layout([
|
||||
aiui.text(f"Platform: {PLATFORM} · workspace: {WORKSPACE_ID} · {summary}"),
|
||||
aiui.board(columns=columns),
|
||||
])
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
port = int(os.getenv("PORT", "8082"))
|
||||
uvicorn.run(app, host=host, port=port)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Board page from aiui jobs — status columns."""
|
||||
|
||||
import os
|
||||
|
||||
import praisonaiui as aiui
|
||||
from praisonaiui.server import create_app
|
||||
|
||||
aiui.set_style("dashboard")
|
||||
aiui.set_pages(["jobs-board"])
|
||||
aiui.set_dashboard(modules=["jobs"], sidebar=True)
|
||||
|
||||
|
||||
def _card(job: dict) -> dict:
|
||||
title = (job.get("prompt") or job.get("id") or "Job")[:80]
|
||||
return aiui.card(title, footer=job.get("status", ""))
|
||||
|
||||
|
||||
@aiui.page("jobs-board", title="Jobs Board", icon="📋", group="Work")
|
||||
async def jobs_board():
|
||||
from praisonaiui.features.jobs import get_job_store
|
||||
|
||||
jobs = get_job_store().list_all()
|
||||
cols = ["queued", "running", "succeeded", "failed"]
|
||||
columns = []
|
||||
for c in cols:
|
||||
cards = [_card(j) for j in jobs if j.get("status") == c]
|
||||
columns.append({"id": c, "title": c.title(), "cards": cards or [aiui.card("(empty)")]})
|
||||
return aiui.layout([aiui.board(columns=columns)])
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host="127.0.0.1", port=int(os.getenv("PORT", "8082")))
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Video Studio example — YAML scenes via PraisonAI Video engine sidecar."""
|
||||
|
||||
import os
|
||||
|
||||
import praisonaiui as aiui
|
||||
from praisonaiui.server import create_app
|
||||
from praisonaiui.video_config import set_video_engine
|
||||
|
||||
aiui.set_style("dashboard")
|
||||
aiui.set_branding(title="Video Studio", logo="🎬")
|
||||
aiui.set_pages(["chat", "video-studio", "jobs", "config"])
|
||||
aiui.set_dashboard(modules=["jobs", "video"], sidebar=True, page_header=True)
|
||||
|
||||
# Optional: override engine URL (default http://127.0.0.1:3921)
|
||||
# set_video_engine(url="http://127.0.0.1:3921")
|
||||
|
||||
_VIDEO_AGENT_INSTRUCTIONS = """You edit PraisonAI Video projects via tools.
|
||||
|
||||
Rules:
|
||||
- scene.yaml must use schemaVersion: 1 with composition and scene blocks.
|
||||
- Always video_lint_scene after video_update_scene; fix errors before render.
|
||||
- Use video_render_project only when lint is clean.
|
||||
- Omit render: in scene.yaml unless the user needs remotion (licensed) or hyperframes (deferred).
|
||||
- Default render path is Playwright; optional render.backend in scene.yaml.
|
||||
- Default project: VIDEO_STUDIO_PROJECT_ID env, or the only listed project.
|
||||
- Skill reference: praisonai-video repo skills/praisonai-video/SKILL.md (YAML-first invariants).
|
||||
"""
|
||||
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiui.providers import PraisonAIProvider
|
||||
from praisonaiui.video_agent_tools import get_video_agent_tools
|
||||
|
||||
_video_agent = Agent(
|
||||
name="Video Editor",
|
||||
instructions=_VIDEO_AGENT_INSTRUCTIONS,
|
||||
llm=os.environ.get("PRAISONAI_MODEL", "gpt-4o-mini"),
|
||||
tools=get_video_agent_tools(),
|
||||
reflection=False,
|
||||
)
|
||||
aiui.set_provider(PraisonAIProvider(agent=_video_agent))
|
||||
except ImportError:
|
||||
pass # Chat works without praisonaiagents; Video tab still uses REST APIs
|
||||
|
||||
|
||||
@aiui.page("video-studio", title="Video", icon="🎬", group="Create", order=20)
|
||||
async def video_studio_page():
|
||||
return aiui.layout(
|
||||
[
|
||||
aiui.html_embed('<div id="video-studio-root"></div>'),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -1,6 +1,6 @@
|
||||
# YAML Chat with Profiles & Goodbye
|
||||
#
|
||||
# What's New (vs chat-yaml/):
|
||||
# What's New (vs yaml/01-chat/):
|
||||
# • profiles — selectable agent personas
|
||||
# • goodbye — farewell message on disconnect
|
||||
#
|
||||
@@ -19,12 +19,21 @@ profiles:
|
||||
- name: Code Expert
|
||||
description: Code review, debugging, and generation
|
||||
icon: 💻
|
||||
instructions: |
|
||||
You are an expert programmer. Write clean, well-commented code.
|
||||
Always include example usage and best practices. Be technical and precise.
|
||||
- name: Writer
|
||||
description: Creative and technical writing
|
||||
icon: ✍️
|
||||
instructions: |
|
||||
You are a creative writer. Use flowing prose, metaphors, and engaging
|
||||
language. Make your responses vivid and expressive. Be artistic and imaginative.
|
||||
- name: Data Analyst
|
||||
description: Data analysis and visualization
|
||||
icon: 📊
|
||||
instructions: |
|
||||
You are a data analyst. Always think in terms of data, metrics, and insights.
|
||||
Use structured analysis, mention specific visualization types, and be analytical.
|
||||
|
||||
starters:
|
||||
- label: "Review my code"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Clean Chat — Dashboard chat without sidebar (YAML version)
|
||||
#
|
||||
# This is the YAML equivalent of examples/python/25-clean-chat/app.py
|
||||
# This is the YAML equivalent of examples/python/26-clean-chat/app.py
|
||||
# All config is protocol-driven through the same /ui-config.json endpoint.
|
||||
#
|
||||
# Run:
|
||||
@@ -1,6 +1,6 @@
|
||||
# YAML Chat with Tools & Model Selection
|
||||
#
|
||||
# What's New (vs chat-yaml-profiles/):
|
||||
# What's New (vs yaml/02-profiles/):
|
||||
# • model — specify which LLM to use
|
||||
# • tools — give the agent built-in tool capabilities
|
||||
#
|
||||
@@ -1,6 +1,6 @@
|
||||
# YAML Chat with All Protocol Features
|
||||
#
|
||||
# What's New (vs chat-yaml-tools/):
|
||||
# What's New (vs yaml/04-tools/):
|
||||
# • features: true — auto-registers all 8 protocol feature modules
|
||||
# (approvals, schedules, memory, sessions, skills, hooks, workflows, config)
|
||||
#
|
||||
@@ -1,31 +0,0 @@
|
||||
# Component Blocks Showcase
|
||||
|
||||
A complete showcase of all shadcn UI component blocks, inspired by [ui.shadcn.com/themes](https://ui.shadcn.com/themes).
|
||||
|
||||
## Dashboard Cards
|
||||
|
||||
Revenue cards, subscription stats, and analytics displays.
|
||||
|
||||
## Forms
|
||||
|
||||
Authentication forms, payment forms, and input variations.
|
||||
|
||||
## Calendar & Date
|
||||
|
||||
Date pickers, calendar widgets, and scheduling components.
|
||||
|
||||
## Charts
|
||||
|
||||
Area charts, bar charts, line graphs for data visualization.
|
||||
|
||||
## Tables
|
||||
|
||||
Data tables with sorting, filtering, and pagination.
|
||||
|
||||
## Navigation
|
||||
|
||||
Tabs, accordions, and dropdown menus for navigation patterns.
|
||||
|
||||
---
|
||||
|
||||
This example uses the auto-download feature to install all 22 components at build time.
|
||||
File renamed without changes.
+2
-4
@@ -1,8 +1,8 @@
|
||||
schemaVersion: 1
|
||||
|
||||
site:
|
||||
title: "Component Blocks Showcase"
|
||||
description: "Showcase of all shadcn UI component blocks - similar to ui.shadcn.com/themes"
|
||||
title: "shadcn Dependencies Installation"
|
||||
description: "Demonstrates auto-download of shadcn UI components at build time"
|
||||
ui: "shadcn"
|
||||
theme:
|
||||
preset: "zinc" # All 22 Tailwind colors available
|
||||
@@ -46,7 +46,5 @@ templates:
|
||||
main: { type: "DocContent" }
|
||||
|
||||
routes:
|
||||
- match: "/blocks/**"
|
||||
template: "blocks"
|
||||
- match: "/docs/**"
|
||||
template: "blocks"
|
||||
@@ -0,0 +1,18 @@
|
||||
# shadcn Dependencies Installation Demo
|
||||
|
||||
This example demonstrates the automatic installation of shadcn UI components at build time using the `dependencies.shadcn` configuration.
|
||||
|
||||
## How it works
|
||||
|
||||
When you build this project, PraisonAIUI will automatically download and install the following 22 shadcn components:
|
||||
|
||||
- **UI Components**: card, button, input, label, avatar, badge, progress, separator, switch, checkbox, radio-group, select, slider, tabs, accordion, dialog, dropdown-menu, popover, tooltip, table
|
||||
- **Advanced Components**: calendar, chart
|
||||
|
||||
## What gets installed
|
||||
|
||||
The components are downloaded from the shadcn/ui registry and installed in your project's components directory. They become available for use in your custom components and templates.
|
||||
|
||||
## Note
|
||||
|
||||
This example demonstrates dependency installation only. For actual component showcases with live demos, see the shadcn documentation at [ui.shadcn.com](https://ui.shadcn.com).
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,6 +1,6 @@
|
||||
# YAML Playground — Multi-agent tabbed UI, no Python needed
|
||||
# YAML Agents Playground — Multi-agent tabbed UI, no Python needed
|
||||
#
|
||||
# What's New (vs chat-yaml-profiles/):
|
||||
# What's New (vs yaml/02-profiles/):
|
||||
# • style: agents — activates tabbed AgentUILayout (sidebar with Agents/History tabs)
|
||||
# • Multiple agent profiles with different system prompts
|
||||
# • Per-agent starter messages
|
||||
@@ -25,15 +25,27 @@ profiles:
|
||||
description: "A helpful general-purpose assistant"
|
||||
icon: "🤖"
|
||||
default: true
|
||||
instructions: |
|
||||
You are a helpful, concise assistant. Answer questions clearly
|
||||
and directly. Provide balanced, well-rounded responses.
|
||||
- name: "Code Expert"
|
||||
description: "Specialized in writing and debugging code"
|
||||
icon: "💻"
|
||||
instructions: |
|
||||
You are a senior software engineer. Focus on code quality,
|
||||
best practices, and technical accuracy. Always include working examples.
|
||||
- name: "Data Analyst"
|
||||
description: "Analyzes data and provides insights"
|
||||
icon: "📊"
|
||||
instructions: |
|
||||
You are a data scientist. Think analytically, suggest appropriate
|
||||
visualizations, and provide data-driven insights. Be methodical and precise.
|
||||
- name: "Creative Writer"
|
||||
description: "Helps with creative writing and content"
|
||||
icon: "✍️"
|
||||
instructions: |
|
||||
You are a creative writer. Use vivid language, storytelling techniques,
|
||||
and engaging narrative structures. Be imaginative and expressive.
|
||||
|
||||
welcome: "👋 Welcome to the AI Playground! Select an agent from the sidebar to get started."
|
||||
|
||||
+8
-3
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "aiui"
|
||||
version = "0.3.114"
|
||||
version = "0.3.121"
|
||||
description = "YAML-driven website generator - CLI and compiler"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
@@ -61,9 +61,9 @@ postgres = [
|
||||
|
||||
praisonai = [
|
||||
# Core agents SDK with knowledge support
|
||||
"praisonaiagents[knowledge,search,crawl]>=1.5.56",
|
||||
"praisonaiagents[knowledge,search,crawl]>=1.6.46",
|
||||
# PraisonAI wrapper (includes CLI, bots, gateway support)
|
||||
"praisonai[bot,api,os]>=2.0.0",
|
||||
"praisonai[bot,api,os]>=4.6.0",
|
||||
# Gateway dependencies
|
||||
"fastapi>=0.115.0",
|
||||
"sse-starlette>=1.6.0",
|
||||
@@ -114,6 +114,10 @@ teams = [
|
||||
"botbuilder-schema>=4.0.0",
|
||||
]
|
||||
|
||||
a2ui = [
|
||||
"praisonaiagents[a2ui]>=1.6.38",
|
||||
]
|
||||
|
||||
all = [
|
||||
"aiui[memory]",
|
||||
"aiui[knowledge]",
|
||||
@@ -129,6 +133,7 @@ all = [
|
||||
"aiui[slack]",
|
||||
"aiui[discord]",
|
||||
"aiui[teams]",
|
||||
"aiui[a2ui]",
|
||||
]
|
||||
|
||||
dev = [
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply modular shell patches to dashboard.js (one-shot)."""
|
||||
from pathlib import Path
|
||||
|
||||
p = Path(__file__).resolve().parents[1] / "src/praisonaiui/templates/frontend/plugins/dashboard.js"
|
||||
text = p.read_text()
|
||||
|
||||
# 1) Extend BUILTIN_VIEWS and registries after theme-picker line
|
||||
old_views = " 'theme-picker': '/plugins/views/theme-picker.js',\n};"
|
||||
new_views = """ 'theme-picker': '/plugins/views/theme-picker.js',
|
||||
feedback: '/plugins/views/feedback.js',
|
||||
jobs: '/plugins/views/jobs.js',
|
||||
auth: '/plugins/views/auth.js',
|
||||
api: '/plugins/views/api.js',
|
||||
};
|
||||
|
||||
const SLOT_REGISTRY = {};
|
||||
let _dashboardPluginManifests = [];
|
||||
|
||||
const aiuiSdk = {
|
||||
version: '1',
|
||||
async fetchJSON(url, options) {
|
||||
const res = await fetch(url, options);
|
||||
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
|
||||
return res.json();
|
||||
},
|
||||
el(tag, attrs, children) {
|
||||
const node = document.createElement(tag);
|
||||
if (attrs) {
|
||||
Object.entries(attrs).forEach(([k, v]) => {
|
||||
if (k === 'className') node.className = v;
|
||||
else if (k === 'text') node.textContent = v;
|
||||
else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2).toLowerCase(), v);
|
||||
else node.setAttribute(k, v);
|
||||
});
|
||||
}
|
||||
(children || []).forEach((c) => { if (c != null) node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); });
|
||||
return node;
|
||||
},
|
||||
themeVar(name, fallback) {
|
||||
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
return v || fallback || '';
|
||||
},
|
||||
createBoard(root, opts) {
|
||||
let destroyed = false;
|
||||
const wrap = document.createElement('motion.div');
|
||||
wrap.className = 'aiui-board';
|
||||
root.appendChild(wrap);
|
||||
async function refresh() {
|
||||
if (destroyed || !opts?.fetch) return;
|
||||
try {
|
||||
const data = await opts.fetch();
|
||||
wrap.innerHTML = '';
|
||||
if (data?._components) data._components.forEach((c) => wrap.appendChild(renderComponent(c)));
|
||||
else if (data?.columns) wrap.appendChild(renderBoard({ type: 'board', columns: data.columns }));
|
||||
} catch (e) { wrap.innerHTML = `<div class="db-alert db-alert-error">${e.message}</div>`; }
|
||||
}
|
||||
refresh();
|
||||
const interval = opts?.pollMs ? setInterval(refresh, opts.pollMs) : null;
|
||||
return { refresh, destroy() { destroyed = true; if (interval) clearInterval(interval); wrap.remove(); } };
|
||||
},
|
||||
};"""
|
||||
if old_views not in text:
|
||||
raise SystemExit("BUILTIN_VIEWS anchor not found")
|
||||
text = text.replace(old_views, new_views.replace('motion.div', 'div'))
|
||||
|
||||
old_api = """window.aiui.components = COMPONENT_REGISTRY;
|
||||
|
||||
// ── Sidebar State"""
|
||||
new_api = """window.aiui.version = '1';
|
||||
window.aiui.components = COMPONENT_REGISTRY;
|
||||
window.aiui.registerSlot = function (name, renderFn) { SLOT_REGISTRY[name] = renderFn; };
|
||||
window.aiui.slots = SLOT_REGISTRY;
|
||||
window.aiui.sdk = aiuiSdk;
|
||||
|
||||
// ── Sidebar State"""
|
||||
text = text.replace(old_api, new_api)
|
||||
|
||||
# 2) CSS after .db-columns
|
||||
css_anchor = " .db-columns { display: grid; gap: 16px; }\n .db-card {"
|
||||
css_insert = """ .db-columns { display: grid; gap: 16px; }
|
||||
.aiui-board-columns { display: flex; gap: 12px; align-items: start; overflow-x: auto; padding-bottom: 8px; }
|
||||
.aiui-board-column { flex: 0 0 280px; display: flex; flex-direction: column; gap: 8px;
|
||||
background: var(--db-card-bg); border: 1px solid var(--db-border); border-radius: var(--db-radius); padding: 10px; min-height: 120px; }
|
||||
.aiui-board-column-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--db-text-dim); margin: 0 0 4px; }
|
||||
.aiui-board-column-cards { display: flex; flex-direction: column; gap: 8px; }
|
||||
.aiui-board-card .db-card { margin: 0; }
|
||||
.db-slot-host { min-height: 0; }
|
||||
.db-sidebar-footer { margin-top: auto; padding: 10px 14px; border-top: 1px solid var(--db-border); font-size: 11px; color: var(--db-text-dim); }
|
||||
.db-mobile-header { display: none; position: fixed; top: 0; left: 0; right: 0; z-index: 40; min-height: 48px; align-items: center; gap: 8px; padding: 8px 12px; background: var(--db-sidebar-bg); border-bottom: 1px solid var(--db-border); }
|
||||
.db-mobile-overlay { display: none; position: fixed; inset: 0; z-index: 35; background: rgba(0,0,0,0.5); }
|
||||
@media (max-width: 1023px) {
|
||||
.db-mobile-header { display: flex; }
|
||||
.db-sidebar { position: fixed; z-index: 45; transform: translateX(-100%); transition: transform 0.2s ease; }
|
||||
.db-sidebar.db-sidebar-open { transform: translateX(0); }
|
||||
.db-mobile-overlay.db-visible { display: block; }
|
||||
.db-main { padding-top: 48px; }
|
||||
}
|
||||
.db-card {"""
|
||||
text = text.replace(css_anchor, css_insert)
|
||||
|
||||
# 3) Helpers before init
|
||||
init_anchor = "async function init() {"
|
||||
helpers = '''
|
||||
function renderSlot(name, parent) {
|
||||
const fn = SLOT_REGISTRY[name];
|
||||
if (!fn || !parent) return;
|
||||
const host = document.createElement('div');
|
||||
host.className = 'db-slot-host';
|
||||
host.dataset.slot = name;
|
||||
try { const node = fn(); if (node) host.appendChild(node); } catch (e) { console.warn(`[AIUI] Slot '${name}':`, e); }
|
||||
parent.appendChild(host);
|
||||
}
|
||||
|
||||
async function loadDashboardPlugins() {
|
||||
_dashboardPluginManifests = [];
|
||||
try {
|
||||
const res = await fetch('/api/dashboard/plugins');
|
||||
if (!res.ok) return;
|
||||
_dashboardPluginManifests = (await res.json()).plugins || [];
|
||||
} catch (e) { console.warn('[AIUI] Dashboard plugins:', e); return; }
|
||||
for (const manifest of _dashboardPluginManifests) {
|
||||
if (manifest.css) {
|
||||
const href = `/dashboard-plugins/${manifest.name}/${manifest.css}`;
|
||||
if (!document.querySelector(`link[data-aiui-plugin-css="${manifest.name}"]`)) {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet'; link.href = href; link.dataset.aiuiPluginCss = manifest.name;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
}
|
||||
if (manifest.entry && !document.querySelector(`script[data-aiui-plugin="${manifest.name}"]`)) {
|
||||
await new Promise((resolve) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = `/dashboard-plugins/${manifest.name}/${manifest.entry}`;
|
||||
script.async = true; script.dataset.aiuiPlugin = manifest.name;
|
||||
script.onload = script.onerror = () => resolve();
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
if (manifest.page && !pagesData.find((pg) => pg.id === manifest.page.id)) pagesData.push(manifest.page);
|
||||
}
|
||||
}
|
||||
|
||||
function setupMobileNav(root, sidebar) {
|
||||
if (!sidebar) return;
|
||||
let overlay = document.getElementById('db-mobile-overlay');
|
||||
if (!overlay) {
|
||||
overlay = document.createElement('div');
|
||||
overlay.id = 'db-mobile-overlay'; overlay.className = 'db-mobile-overlay';
|
||||
overlay.addEventListener('click', () => { sidebar.classList.remove('db-sidebar-open'); overlay.classList.remove('db-visible'); });
|
||||
root.insertBefore(overlay, root.firstChild);
|
||||
}
|
||||
if (!document.getElementById('db-mobile-header')) {
|
||||
const header = document.createElement('motion.div');
|
||||
header.id = 'db-mobile-header'; header.className = 'db-mobile-header';
|
||||
const menuBtn = document.createElement('button');
|
||||
menuBtn.type = 'button'; menuBtn.textContent = '☰';
|
||||
menuBtn.style.cssText = 'background:transparent;border:1px solid var(--db-border);color:var(--db-text);border-radius:6px;padding:6px 10px;cursor:pointer';
|
||||
menuBtn.addEventListener('click', () => {
|
||||
sidebar.classList.toggle('db-sidebar-open');
|
||||
overlay.classList.toggle('db-visible', sidebar.classList.contains('db-sidebar-open'));
|
||||
});
|
||||
const title = document.createElement('span'); title.id = 'db-mobile-title'; title.style.fontWeight = '600';
|
||||
header.appendChild(menuBtn); header.appendChild(title);
|
||||
root.insertBefore(header, overlay.nextSibling);
|
||||
}
|
||||
}
|
||||
|
||||
'''
|
||||
helpers = helpers.replace('motion.div', 'motion.div').replace("createElement('motion.div')", "createElement('motion.div')")
|
||||
helpers = helpers.replace('motion.div', 'div')
|
||||
text = text.replace(init_anchor, helpers + init_anchor)
|
||||
|
||||
# 4) init awaits buildDashboard
|
||||
text = text.replace(
|
||||
" // Build the dashboard (after theme is applied)\n buildDashboard();\n}",
|
||||
" await buildDashboard();\n}",
|
||||
)
|
||||
|
||||
# 5) buildDashboard plugins + mobile
|
||||
text = text.replace(
|
||||
" dashboardConfig = uiConfig.dashboard || {};\n } catch (e) {",
|
||||
" dashboardConfig = uiConfig.dashboard || {};\n window.__aiuiDashboardModules = dashboardConfig.modules || [];\n await loadDashboardPlugins();\n } catch (e) {",
|
||||
)
|
||||
text = text.replace(
|
||||
""" if (showSidebar) {
|
||||
const sidebar = buildSidebar(pagesData);
|
||||
root.appendChild(sidebar);
|
||||
}
|
||||
|
||||
root.appendChild(main);""",
|
||||
""" let sidebar = null;
|
||||
if (showSidebar) {
|
||||
sidebar = buildSidebar(pagesData);
|
||||
const slotFooter = document.createElement('div');
|
||||
slotFooter.className = 'db-sidebar-footer';
|
||||
renderSlot('shell:sidebar:footer', slotFooter);
|
||||
if (slotFooter.children.length) sidebar.appendChild(slotFooter);
|
||||
root.appendChild(sidebar);
|
||||
setupMobileNav(root, sidebar);
|
||||
}
|
||||
const shellHeader = document.createElement('div');
|
||||
shellHeader.id = 'db-shell-header-slot';
|
||||
renderSlot('shell:header', shellHeader);
|
||||
if (shellHeader.children.length) root.insertBefore(shellHeader, main);
|
||||
root.appendChild(main);""",
|
||||
)
|
||||
|
||||
# 6) selectPage toolbar
|
||||
old_sel = """ const headerHtml = showPageHeader ? `
|
||||
<motion.div class="db-page-header">
|
||||
<h1 class="db-page-title">${page.icon || ''} ${page.title}</h1>
|
||||
${page.description ? `<p class="db-page-desc">${page.description}</p>` : ''}
|
||||
</motion.div>
|
||||
` : '';
|
||||
main.innerHTML = `
|
||||
${headerHtml}
|
||||
<motion.div data-page="${pageId}" id="db-page-content"></motion.div>
|
||||
`;
|
||||
|
||||
const container = document.querySelector(`[data-page="${pageId}"]`);"""
|
||||
old_sel = old_sel.replace('motion.div', 'div')
|
||||
new_sel = """ const toolbarSlotId = `page:${pageId}:toolbar`;
|
||||
const headerHtml = showPageHeader ? `
|
||||
<div class="db-page-header">
|
||||
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap">
|
||||
<div>
|
||||
<h1 class="db-page-title">${page.icon || ''} ${page.title}</h1>
|
||||
${page.description ? `<p class="db-page-desc">${page.description}</p>` : ''}
|
||||
</div>
|
||||
<div id="db-page-toolbar-slot" class="db-slot-host" data-slot="${toolbarSlotId}"></div>
|
||||
</div>
|
||||
</div>
|
||||
` : '';
|
||||
main.innerHTML = `${headerHtml}<motion.div data-page="${pageId}" id="db-page-content"></motion.div>`;
|
||||
if (showPageHeader) {
|
||||
const toolbarHost = document.getElementById('db-page-toolbar-slot');
|
||||
if (toolbarHost && SLOT_REGISTRY[toolbarSlotId]) {
|
||||
try { const node = SLOT_REGISTRY[toolbarSlotId](); if (node) toolbarHost.appendChild(node); } catch (e) {}
|
||||
}
|
||||
}
|
||||
const mobileTitle = document.getElementById('db-mobile-title');
|
||||
if (mobileTitle) mobileTitle.textContent = page.title || '';
|
||||
const container = document.querySelector(`[data-page="${pageId}"]`);"""
|
||||
new_sel = new_sel.replace('motion.div', 'div')
|
||||
if old_sel not in text:
|
||||
old_sel2 = """ const headerHtml = showPageHeader ? `
|
||||
<div class="db-page-header">
|
||||
<h1 class="db-page-title">${page.icon || ''} ${page.title}</h1>
|
||||
${page.description ? `<p class="db-page-desc">${page.description}</p>` : ''}
|
||||
</motion.div>
|
||||
` : '';
|
||||
main.innerHTML = `
|
||||
${headerHtml}
|
||||
<motion.div data-page="${pageId}" id="db-page-content"></motion.div>
|
||||
`;
|
||||
|
||||
const container = document.querySelector(`[data-page="${pageId}"]`);""".replace('motion.div','motion.div')
|
||||
# use correct version
|
||||
old_sel2 = """ const headerHtml = showPageHeader ? `
|
||||
<div class="db-page-header">
|
||||
<h1 class="db-page-title">${page.icon || ''} ${page.title}</h1>
|
||||
${page.description ? `<p class="db-page-desc">${page.description}</p>` : ''}
|
||||
</div>
|
||||
` : '';
|
||||
main.innerHTML = `
|
||||
${headerHtml}
|
||||
<div data-page="${pageId}" id="db-page-content"></div>
|
||||
`;
|
||||
|
||||
const container = document.querySelector(`[data-page="${pageId}"]`);"""
|
||||
text = text.replace(old_sel2, new_sel)
|
||||
else:
|
||||
text = text.replace(old_sel, new_sel)
|
||||
|
||||
# 7) board case + renderBoard
|
||||
text = text.replace(
|
||||
" case 'form_action': return renderFormAction(comp);\n default: {",
|
||||
" case 'form_action': return renderFormAction(comp);\n case 'board': return renderBoard(comp);\n default: {",
|
||||
)
|
||||
if "function renderBoard" not in text:
|
||||
text = text.replace(
|
||||
"function renderCard(comp) {",
|
||||
'''function renderBoard(comp) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'aiui-board';
|
||||
const row = document.createElement('div');
|
||||
row.className = 'aiui-board-columns';
|
||||
(comp.columns || []).forEach((col) => {
|
||||
const colEl = document.createElement('div');
|
||||
colEl.className = 'aiui-board-column';
|
||||
const title = document.createElement('div');
|
||||
title.className = 'aiui-board-column-title';
|
||||
title.textContent = col.title || col.id || 'Column';
|
||||
colEl.appendChild(title);
|
||||
const cardsWrap = document.createElement('div');
|
||||
cardsWrap.className = 'aiui-board-column-cards';
|
||||
(col.cards || []).forEach((cardComp) => {
|
||||
const cardWrap = document.createElement('div');
|
||||
cardWrap.className = 'aiui-board-card';
|
||||
const normalized = cardComp?.type ? cardComp : {
|
||||
type: 'card', title: cardComp.title || cardComp.name || 'Card',
|
||||
value: cardComp.value, footer: cardComp.footer || cardComp.assignee || cardComp.status,
|
||||
};
|
||||
cardWrap.appendChild(renderComponent(normalized));
|
||||
cardsWrap.appendChild(cardWrap);
|
||||
});
|
||||
colEl.appendChild(cardsWrap);
|
||||
row.appendChild(colEl);
|
||||
});
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderCard(comp) {''',
|
||||
)
|
||||
|
||||
# 8) default slot before export
|
||||
if "registerSlot('shell:sidebar:footer'" not in text:
|
||||
text = text.replace(
|
||||
"export { init, onContentChange };",
|
||||
"""window.aiui.registerSlot('shell:sidebar:footer', () => {
|
||||
const el = document.createElement('div');
|
||||
el.innerHTML = '<span class="db-health-dot" style="color:var(--db-text-dim)">●</span> <span class="db-health-label">Checking…</span>';
|
||||
fetch('/api/health').then((r) => r.json()).then((d) => {
|
||||
const ok = d && (d.status === 'ok' || d.status === 'healthy');
|
||||
const dot = el.querySelector('.db-health-dot');
|
||||
const label = el.querySelector('.db-health-label');
|
||||
if (dot) dot.style.color = ok ? '#22c55e' : '#ef4444';
|
||||
if (label) label.textContent = ok ? 'Connected' : 'Degraded';
|
||||
}).catch(() => { const label = el.querySelector('.db-health-label'); if (label) label.textContent = 'Offline'; });
|
||||
return el;
|
||||
});
|
||||
|
||||
export { init, onContentChange };""",
|
||||
)
|
||||
|
||||
p.write_text(text)
|
||||
print("patched ok", len(text))
|
||||
Generated
+185
@@ -45,6 +45,8 @@
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@a2ui/react": "^0.10.0",
|
||||
"@a2ui/web_core": "^0.10.0",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/node": "^24.10.1",
|
||||
@@ -65,8 +67,87 @@
|
||||
"vite-plugin-dts": "^4.5.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@a2ui/react": ">=0.10.0",
|
||||
"@a2ui/web_core": ">=0.10.0",
|
||||
"react": ">=18.0.0",
|
||||
"react-dom": ">=18.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@a2ui/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@a2ui/web_core": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@a2ui/markdown-it": {
|
||||
"version": "0.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@a2ui/markdown-it/-/markdown-it-0.0.4.tgz",
|
||||
"integrity": "sha512-uaYNywdiHDH4i//pHpyckFnApvOshiUNT2Xl8awceNjL1XAX/gqtki9GRUJQ26RE7id2+aPNgOy1W6mXNBxriQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"dompurify": "^3.3.1",
|
||||
"markdown-it": "^14.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@a2ui/web_core": "^0.9.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@a2ui/react": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@a2ui/react/-/react-0.10.0.tgz",
|
||||
"integrity": "sha512-LeyrHUv7lNkLqrxr+qM2XybCB5ok/8gm5fVcs2foPiPV0KN6XtZGDKk23Kt3oitRHJp7vFOeyDD9y+UYWh4Y3A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@a2ui/markdown-it": "^0.0.4",
|
||||
"@a2ui/web_core": "^0.10.0",
|
||||
"clsx": "^2.1.0",
|
||||
"markdown-it": "^14.0.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0",
|
||||
"zod": "^3.23.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@a2ui/react/node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/@a2ui/web_core": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@a2ui/web_core/-/web_core-0.10.0.tgz",
|
||||
"integrity": "sha512-pSieKxaB6aZWBL2LjZfKo4/1v4xMWylczpzte1ehuGH0kfNGf/iTd73PKsIKYx3b0FNWTbD8gAloOM4JCVqUig==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@preact/signals-core": "^1.13.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"zod": "^3.25.76",
|
||||
"zod-to-json-schema": "^3.25.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@a2ui/web_core/node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
@@ -1276,6 +1357,17 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@preact/signals-core": {
|
||||
"version": "1.14.2",
|
||||
"resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.2.tgz",
|
||||
"integrity": "sha512-RZHdBj9ZF4n40Rp4jS052EHHjBWf96P9oNdXPfhQTovCuWY9iQn3Gq+gOTJSgBO9A/JBuPfMOWsSX/lIU9Pc/A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/number": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||
@@ -3751,6 +3843,14 @@
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
@@ -4872,6 +4972,16 @@
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.5",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz",
|
||||
"integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==",
|
||||
"dev": true,
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.307",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz",
|
||||
@@ -6145,6 +6255,16 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uc.micro": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/local-pkg": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz",
|
||||
@@ -6257,6 +6377,37 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it": {
|
||||
"version": "14.1.1",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
|
||||
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1",
|
||||
"entities": "^4.4.0",
|
||||
"linkify-it": "^5.0.0",
|
||||
"mdurl": "^2.0.0",
|
||||
"punycode.js": "^2.3.1",
|
||||
"uc.micro": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"markdown-it": "bin/markdown-it.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it/node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-table": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
|
||||
@@ -6549,6 +6700,13 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/micromark": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
|
||||
@@ -7471,6 +7629,16 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode.js": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/quansync": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
|
||||
@@ -8214,6 +8382,13 @@
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uc.micro": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ufo": {
|
||||
"version": "1.6.3",
|
||||
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
|
||||
@@ -8639,6 +8814,16 @@
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zod-to-json-schema": {
|
||||
"version": "3.25.2",
|
||||
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
|
||||
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.28 || ^4"
|
||||
}
|
||||
},
|
||||
"node_modules/zod-validation-error": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
|
||||
|
||||
@@ -48,9 +48,19 @@
|
||||
"directory": "src/frontend"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@a2ui/react": ">=0.10.0",
|
||||
"@a2ui/web_core": ">=0.10.0",
|
||||
"react": ">=18.0.0",
|
||||
"react-dom": ">=18.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@a2ui/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@a2ui/web_core": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
@@ -88,6 +98,8 @@
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@a2ui/react": "^0.10.0",
|
||||
"@a2ui/web_core": "^0.10.0",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/node": "^24.10.1",
|
||||
|
||||
+71
-10
@@ -10,6 +10,18 @@ import { Toc } from './Toc'
|
||||
import { Footer } from './Footer'
|
||||
import { ChatLayout, AgentUILayout, CopilotWidget, PlaygroundLayout } from './layouts'
|
||||
|
||||
function SkipLink({ enabled }: { enabled?: boolean }) {
|
||||
if (!enabled) return null
|
||||
return (
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 z-50 bg-primary text-primary-foreground px-4 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [config, setConfig] = useState<UIConfig>({})
|
||||
const [nav, setNav] = useState<DocsNav>({})
|
||||
@@ -100,8 +112,14 @@ export default function App() {
|
||||
|
||||
// Update SEO meta tags dynamically
|
||||
const updateSEO = (title: string, path: string, description?: string) => {
|
||||
// Update title
|
||||
document.title = `${title} | ${config.site?.title || 'Documentation'}`
|
||||
// Update title using SEO titleTemplate if available
|
||||
const titleTemplate = config.seo?.titleTemplate || '%s | %s'
|
||||
const siteName = config.site?.title || 'Documentation'
|
||||
if (titleTemplate.includes('%s')) {
|
||||
document.title = titleTemplate.replace('%s', title).replace('%s', siteName)
|
||||
} else {
|
||||
document.title = `${title} | ${siteName}`
|
||||
}
|
||||
|
||||
// Update canonical URL
|
||||
let canonical = document.querySelector('link[rel="canonical"]') as HTMLLinkElement
|
||||
@@ -137,6 +155,30 @@ export default function App() {
|
||||
document.head.appendChild(ogUrl)
|
||||
}
|
||||
ogUrl.content = window.location.origin + path
|
||||
|
||||
// Update Twitter meta tags if configured
|
||||
if (config.seo?.twitter) {
|
||||
Object.entries(config.seo.twitter).forEach(([key, value]) => {
|
||||
let twitterMeta = document.querySelector(`meta[name="twitter:${key}"]`) as HTMLMetaElement
|
||||
if (!twitterMeta) {
|
||||
twitterMeta = document.createElement('meta')
|
||||
twitterMeta.name = `twitter:${key}`
|
||||
document.head.appendChild(twitterMeta)
|
||||
}
|
||||
twitterMeta.content = value
|
||||
})
|
||||
}
|
||||
|
||||
// Set default image if available
|
||||
if (config.seo?.defaultImage) {
|
||||
let ogImage = document.querySelector('meta[property="og:image"]') as HTMLMetaElement
|
||||
if (!ogImage) {
|
||||
ogImage = document.createElement('meta')
|
||||
ogImage.setAttribute('property', 'og:image')
|
||||
document.head.appendChild(ogImage)
|
||||
}
|
||||
ogImage.content = config.seo.defaultImage
|
||||
}
|
||||
}
|
||||
|
||||
const handleItemClick = (item: NavItem) => {
|
||||
@@ -186,7 +228,7 @@ export default function App() {
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<div className="w-full max-w-4xl px-6">
|
||||
{zones?.hero && <ZoneWidgets widgets={zones.hero} />}
|
||||
{zones?.hero && zones.hero.length > 0 && <ZoneWidgets widgets={zones.hero} />}
|
||||
<Content config={config} routes={routes} selectedItem={selectedItem} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -195,19 +237,28 @@ export default function App() {
|
||||
// Full width content
|
||||
return (
|
||||
<div className="px-6">
|
||||
{zones?.hero && <ZoneWidgets widgets={zones.hero} />}
|
||||
{zones?.hero && zones.hero.length > 0 && <ZoneWidgets widgets={zones.hero} />}
|
||||
<Content config={config} routes={routes} selectedItem={selectedItem} />
|
||||
</div>
|
||||
)
|
||||
case 'FlexibleLayout':
|
||||
// WordPress-style zones layout
|
||||
case 'FlexibleLayout': {
|
||||
// WordPress-style zones layout with navigation invariant
|
||||
// When docs nav exists but no leftSidebar zone configured, render default nav
|
||||
const hasExplicitLeftSidebar = (zones?.leftSidebar?.length ?? 0) > 0
|
||||
const hasNavigation = (nav?.items?.length ?? 0) > 0
|
||||
const shouldRenderNavigation = hasExplicitLeftSidebar || hasNavigation
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{zones?.hero && <ZoneWidgets widgets={zones.hero} />}
|
||||
{zones?.hero && zones.hero.length > 0 && <ZoneWidgets widgets={zones.hero} />}
|
||||
<div className="flex flex-1">
|
||||
{zones?.leftSidebar && (
|
||||
{shouldRenderNavigation && (
|
||||
<aside className="w-64 border-r p-4 hidden md:block">
|
||||
<ZoneWidgets widgets={zones.leftSidebar} />
|
||||
{hasExplicitLeftSidebar ? (
|
||||
<ZoneWidgets widgets={zones?.leftSidebar} />
|
||||
) : (
|
||||
<Sidebar nav={nav} activeItem={activeItemPath} onItemClick={handleItemClick} />
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
@@ -215,13 +266,19 @@ export default function App() {
|
||||
</div>
|
||||
<Toc selectedItem={selectedItem} zones={zones} />
|
||||
</div>
|
||||
{zones?.bottomNav && (
|
||||
{zones?.bottomNav && zones.bottomNav.length > 0 && (
|
||||
<div className="border-t p-4 bg-muted/30">
|
||||
<ZoneWidgets widgets={zones.bottomNav} />
|
||||
</div>
|
||||
)}
|
||||
{zones?.footer && zones.footer.length > 0 && (
|
||||
<div className="border-t p-4 bg-muted/50">
|
||||
<ZoneWidgets widgets={zones.footer} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
case 'ThreeColumnLayout':
|
||||
default:
|
||||
// Classic: Sidebar + Content + TOC with zones
|
||||
@@ -271,6 +328,7 @@ export default function App() {
|
||||
if (config.chat?.enabled) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<SkipLink enabled={config.a11y?.skipToContent} />
|
||||
<Header config={config} />
|
||||
{renderLayout()}
|
||||
<Footer config={config} />
|
||||
@@ -280,6 +338,7 @@ export default function App() {
|
||||
}
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<SkipLink enabled={config.a11y?.skipToContent} />
|
||||
<Header config={config} />
|
||||
{renderLayout()}
|
||||
<Footer config={config} />
|
||||
@@ -293,6 +352,7 @@ export default function App() {
|
||||
if (layoutMode && ['bottom-right', 'bottom-left', 'top-right', 'top-left'].includes(layoutMode)) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<SkipLink enabled={config.a11y?.skipToContent} />
|
||||
<Header config={config} />
|
||||
{renderLayout()}
|
||||
<Footer config={config} />
|
||||
@@ -303,6 +363,7 @@ export default function App() {
|
||||
}
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<SkipLink enabled={config.a11y?.skipToContent} />
|
||||
<Header config={config} />
|
||||
{renderLayout()}
|
||||
<Footer config={config} />
|
||||
|
||||
@@ -82,7 +82,7 @@ export function Content({ config, routes, selectedItem }: ContentProps) {
|
||||
|
||||
if (selectedItem) {
|
||||
return (
|
||||
<main className="flex-1 p-8 max-w-3xl">
|
||||
<main id="main-content" className="flex-1 p-8 max-w-3xl">
|
||||
{loadingContent ? (
|
||||
<div className="text-muted-foreground">Loading content...</div>
|
||||
) : markdown ? (
|
||||
|
||||
@@ -10,21 +10,36 @@ export function Header({ config }: { config: UIConfig }) {
|
||||
: config.components?.header
|
||||
const header = (headerComponent?.props || headerSlot?.props) as {
|
||||
logoText?: string
|
||||
logoImage?: string
|
||||
links?: { label: string; href: string }[]
|
||||
cta?: { label: string; href: string }
|
||||
} | undefined
|
||||
|
||||
// Get logo configuration - support both header.logoImage and config.logo
|
||||
const logoConfig = config.logo
|
||||
const isDarkMode = document.documentElement.classList.contains('dark')
|
||||
const logoSrc = header?.logoImage || (logoConfig ? (isDarkMode ? (logoConfig.dark || logoConfig.light) : (logoConfig.light || logoConfig.dark)) : null)
|
||||
const logoHref = logoConfig?.href || '/'
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-border/50 bg-background/80 backdrop-blur-xl supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="max-w-screen-2xl mx-auto flex h-16 items-center px-6 gap-8">
|
||||
<a href="/" className="flex items-center gap-3 group cursor-pointer" onClick={(e) => {
|
||||
<a href={logoHref} className="flex items-center gap-3 group cursor-pointer" onClick={(e) => {
|
||||
e.preventDefault()
|
||||
window.history.pushState({}, '', '/')
|
||||
window.history.pushState({}, '', logoHref)
|
||||
window.location.reload()
|
||||
}}>
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary/80 to-primary flex items-center justify-center shadow-lg shadow-primary/20 group-hover:shadow-primary/40 transition-shadow">
|
||||
<span className="text-primary-foreground text-xs font-bold">AI</span>
|
||||
</div>
|
||||
{logoSrc ? (
|
||||
<img
|
||||
src={logoSrc}
|
||||
alt="Logo"
|
||||
className="w-8 h-8 rounded-lg shadow-lg shadow-primary/20 group-hover:shadow-primary/40 transition-shadow object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary/80 to-primary flex items-center justify-center shadow-lg shadow-primary/20 group-hover:shadow-primary/40 transition-shadow">
|
||||
<span className="text-primary-foreground text-xs font-bold">AI</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="font-semibold text-lg tracking-tight">{header?.logoText || config.site?.title || 'PraisonAIUI'}</span>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Button } from '../components/ui/button'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../components/ui/dialog'
|
||||
import { Badge } from '../components/ui/badge'
|
||||
import { Checkbox } from '../components/ui/checkbox'
|
||||
import { Textarea } from '../components/ui/textarea'
|
||||
|
||||
interface ApprovalRequest {
|
||||
id: string
|
||||
tool_name: string
|
||||
arguments: Record<string, any>
|
||||
risk_level: string
|
||||
agent_name?: string
|
||||
description?: string
|
||||
session_id?: string
|
||||
}
|
||||
|
||||
const riskColors: Record<string, string> = {
|
||||
low: 'bg-green-500',
|
||||
medium: 'bg-yellow-500',
|
||||
high: 'bg-orange-500',
|
||||
critical: 'bg-red-500',
|
||||
}
|
||||
|
||||
const riskIcons: Record<string, string> = {
|
||||
low: '✅',
|
||||
medium: '⚠️',
|
||||
high: '🟠',
|
||||
critical: '🔴',
|
||||
}
|
||||
|
||||
export function ApprovalModal() {
|
||||
const [approval, setApproval] = useState<ApprovalRequest | null>(null)
|
||||
const [reason, setReason] = useState('')
|
||||
const [alwaysAllow, setAlwaysAllow] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleApprovalRequired = (event: CustomEvent<ApprovalRequest>) => {
|
||||
setApproval(event.detail)
|
||||
setReason('')
|
||||
setAlwaysAllow(false)
|
||||
}
|
||||
|
||||
window.addEventListener('approval-required', handleApprovalRequired as EventListener)
|
||||
return () => {
|
||||
window.removeEventListener('approval-required', handleApprovalRequired as EventListener)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleApprove = async () => {
|
||||
if (!approval) return
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/approvals/${approval.id}/approve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
reason,
|
||||
approver: 'user',
|
||||
always: alwaysAllow,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setApproval(null)
|
||||
setReason('')
|
||||
setAlwaysAllow(false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to approve:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeny = async () => {
|
||||
if (!approval) return
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/approvals/${approval.id}/deny`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
reason,
|
||||
approver: 'user',
|
||||
always: alwaysAllow,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setApproval(null)
|
||||
setReason('')
|
||||
setAlwaysAllow(false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to deny:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!approval) return null
|
||||
|
||||
return (
|
||||
<Dialog open={!!approval} onOpenChange={(open) => !open && !loading && setApproval(null)}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{riskIcons[approval.risk_level] || '⚠️'} Tool Execution Approval Required
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{approval.agent_name || 'Agent'} wants to execute: {approval.tool_name}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="my-4 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={riskColors[approval.risk_level] || 'bg-yellow-500'}>
|
||||
Risk Level: {approval.risk_level}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{approval.description && (
|
||||
<p className="text-sm">{approval.description}</p>
|
||||
)}
|
||||
|
||||
{Object.keys(approval.arguments).length > 0 && (
|
||||
<div className="bg-gray-100 dark:bg-gray-800 p-3 rounded">
|
||||
<p className="text-sm font-semibold mb-2">Tool Arguments:</p>
|
||||
<pre className="text-xs overflow-auto max-h-40">
|
||||
{JSON.stringify(approval.arguments, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
placeholder="Optional: Provide a reason for your decision..."
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
className="min-h-[60px]"
|
||||
/>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="always"
|
||||
checked={alwaysAllow}
|
||||
onCheckedChange={(checked) => setAlwaysAllow(checked as boolean)}
|
||||
/>
|
||||
<label htmlFor="always" className="text-sm cursor-pointer">
|
||||
Always allow this tool for this session
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setApproval(null)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeny}
|
||||
disabled={loading}
|
||||
>
|
||||
Deny
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleApprove}
|
||||
disabled={loading}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { ChatMessages } from './ChatMessages'
|
||||
import { ChatInput } from './ChatInput'
|
||||
import { StarterMessages } from './StarterMessages'
|
||||
import { TaskSidebar } from './TaskSidebar'
|
||||
import { ApprovalModal } from './ApprovalModal'
|
||||
|
||||
interface ChatAreaProps {
|
||||
config?: ChatConfig
|
||||
@@ -55,6 +56,7 @@ export function ChatArea({ config, className = '', sessionId: externalSessionId,
|
||||
content: run.content || run.message || '',
|
||||
timestamp: run.timestamp || new Date().toISOString(),
|
||||
...(run.toolCalls ? { toolCalls: run.toolCalls } : {}),
|
||||
...(run.elements ? { elements: run.elements } : {}),
|
||||
}))
|
||||
setMessages(loaded)
|
||||
}
|
||||
@@ -71,6 +73,7 @@ export function ChatArea({ config, className = '', sessionId: externalSessionId,
|
||||
currentResponse,
|
||||
toolCalls,
|
||||
thinkingSteps,
|
||||
pendingElements,
|
||||
sendMessage,
|
||||
cancel,
|
||||
} = useSSE({
|
||||
@@ -152,6 +155,7 @@ export function ChatArea({ config, className = '', sessionId: externalSessionId,
|
||||
currentResponse={currentResponse}
|
||||
thinkingSteps={thinkingSteps}
|
||||
toolCalls={toolCalls}
|
||||
pendingElements={pendingElements}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
)}
|
||||
@@ -171,6 +175,9 @@ export function ChatArea({ config, className = '', sessionId: externalSessionId,
|
||||
sessionId={externalSessionId || 'default'}
|
||||
onTaskClick={handleTaskClick}
|
||||
/>
|
||||
|
||||
{/* Global approval modal */}
|
||||
<ApprovalModal />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ interface ChatMessagesProps {
|
||||
currentResponse: string
|
||||
thinkingSteps: string[]
|
||||
toolCalls: ToolCall[]
|
||||
pendingElements?: (import('../types').MessageElementUnion | Record<string, unknown>)[]
|
||||
isStreaming: boolean
|
||||
sessionId?: string
|
||||
}
|
||||
@@ -22,6 +23,7 @@ export function ChatMessages({
|
||||
currentResponse,
|
||||
thinkingSteps,
|
||||
toolCalls,
|
||||
pendingElements = [],
|
||||
isStreaming,
|
||||
sessionId,
|
||||
}: ChatMessagesProps) {
|
||||
@@ -45,14 +47,27 @@ export function ChatMessages({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{currentResponse && (
|
||||
{(currentResponse || pendingElements.length > 0) ? (
|
||||
<div className="flex gap-3">
|
||||
<Avatar role="assistant" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<MarkdownContent content={currentResponse} />
|
||||
<span className="inline-block w-2 h-4 bg-primary animate-pulse ml-0.5 rounded-sm" />
|
||||
{currentResponse && (
|
||||
<>
|
||||
<MarkdownContent content={currentResponse} />
|
||||
<span className="inline-block w-2 h-4 bg-primary animate-pulse ml-0.5 rounded-sm" />
|
||||
</>
|
||||
)}
|
||||
{pendingElements.length > 0 && (
|
||||
<div className={`space-y-3 ${currentResponse ? 'mt-3' : ''}`}>
|
||||
{pendingElements.map((element, i) => (
|
||||
<ElementRenderer key={i} element={element} className="w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<StreamLoadingBubble />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -61,6 +76,24 @@ export function ChatMessages({
|
||||
}
|
||||
|
||||
// -- Avatar ---
|
||||
function StreamLoadingBubble() {
|
||||
return (
|
||||
<div className="flex gap-3" aria-live="polite" aria-label="Assistant is responding">
|
||||
<Avatar role="assistant" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="inline-flex items-center gap-2 rounded-2xl bg-muted/60 px-4 py-3 text-sm text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1" aria-hidden="true">
|
||||
<span className="w-2 h-2 rounded-full bg-primary/70 animate-bounce [animation-delay:0ms]" />
|
||||
<span className="w-2 h-2 rounded-full bg-primary/70 animate-bounce [animation-delay:150ms]" />
|
||||
<span className="w-2 h-2 rounded-full bg-primary/70 animate-bounce [animation-delay:300ms]" />
|
||||
</span>
|
||||
<span>Generating response…</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Avatar({ role }: { role: string }) {
|
||||
if (role === 'user') {
|
||||
return (
|
||||
|
||||
@@ -285,7 +285,7 @@ export function CodeBlock({ code, language, className = '' }: CodeBlockProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// Unified element renderer supporting all element types and display modes
|
||||
import { SurfaceHost } from '../surfaces/SurfaceHost'
|
||||
interface ElementRendererProps {
|
||||
element: MessageElementUnion | Record<string, unknown>
|
||||
className?: string
|
||||
@@ -361,6 +361,18 @@ export function ElementRenderer({ element, className = '' }: ElementRendererProp
|
||||
/>
|
||||
)
|
||||
}
|
||||
case 'a2ui_surface': {
|
||||
const el = element as Record<string, unknown>
|
||||
const messages = (el.messages ?? []) as Record<string, unknown>[]
|
||||
return (
|
||||
<SurfaceHost
|
||||
mode="auto"
|
||||
messages={messages}
|
||||
surfaceId={el.surface_id as string | undefined}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
default:
|
||||
// Fallback for legacy/unknown element types
|
||||
if ('url' in element && typeof element.url === 'string') {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import type { ToolCall } from '../types'
|
||||
import { ElementRenderer } from './MultimediaElements'
|
||||
import { SurfaceHost } from '../surfaces/SurfaceHost'
|
||||
|
||||
interface ToolCallDisplayProps {
|
||||
toolCall: ToolCall
|
||||
@@ -35,6 +37,11 @@ export function ToolCallDisplay({ toolCall }: ToolCallDisplayProps) {
|
||||
<span className="font-medium truncate">
|
||||
{toolCall.icon && <span className="mr-1">{toolCall.icon}</span>}
|
||||
{displayLabel}
|
||||
{toolCall.capability === 'stub' && (
|
||||
<span className="ml-1 text-[10px] text-orange-600 bg-orange-100 px-1 py-0.5 rounded">
|
||||
⚠️ DEMO
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{toolCall.error && (
|
||||
@@ -57,6 +64,44 @@ export function ToolCallDisplay({ toolCall }: ToolCallDisplayProps) {
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="p-2 border-t space-y-2">
|
||||
{toolCall.capability === 'stub' && (
|
||||
<div className="p-2 rounded bg-orange-50 border border-orange-200 text-orange-800 text-xs">
|
||||
<div className="font-medium flex items-center gap-1">
|
||||
⚠️ Simulated Tool
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
This tool returns demonstration data, not real results.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{toolCall.warning && (
|
||||
<div className="p-2 rounded bg-yellow-50 border border-yellow-200 text-yellow-800 text-xs">
|
||||
<div className="font-medium">⚠️ Warning</div>
|
||||
<div className="mt-1">{toolCall.warning}</div>
|
||||
</div>
|
||||
)}
|
||||
{toolCall.elements?.length ? (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Preview:</span>
|
||||
<div className="mt-1 space-y-2">
|
||||
{toolCall.elements.map((element, i) => (
|
||||
<ElementRenderer key={i} element={element} className="max-w-sm" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{toolCall.a2ui?.messages?.length ? (
|
||||
<div>
|
||||
<span className="text-muted-foreground">UI:</span>
|
||||
<div className="mt-1">
|
||||
<SurfaceHost
|
||||
mode="auto"
|
||||
messages={toolCall.a2ui.messages}
|
||||
surfaceId={toolCall.a2ui.surface_id ?? toolCall.surface_id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{toolCall.args && Object.keys(toolCall.args).length > 0 && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Arguments:</span>
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface SessionStreamState {
|
||||
currentResponse: string
|
||||
toolCalls: ToolCall[]
|
||||
thinkingSteps: string[]
|
||||
pendingElements: (import('../types').MessageElementUnion | Record<string, unknown>)[]
|
||||
}
|
||||
|
||||
const defaultState = (): SessionStreamState => ({
|
||||
@@ -25,6 +26,7 @@ const defaultState = (): SessionStreamState => ({
|
||||
currentResponse: '',
|
||||
toolCalls: [],
|
||||
thinkingSteps: [],
|
||||
pendingElements: [],
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -119,6 +121,7 @@ export function startStream(
|
||||
currentResponse: '',
|
||||
toolCalls: [],
|
||||
thinkingSteps: [],
|
||||
pendingElements: [],
|
||||
})
|
||||
// Reset tool call dedup map for this new stream
|
||||
entry.toolCallMap = new Map()
|
||||
@@ -264,6 +267,11 @@ function handleStoreEvent(
|
||||
|
||||
case 'tool_call_completed':
|
||||
case 'team_tool_call_completed': {
|
||||
const extra = event.extra_data as Record<string, unknown> | undefined
|
||||
const a2uiRaw = (event.a2ui ?? extra?.a2ui) as Record<string, unknown>[] | undefined
|
||||
const mediaEls = (event.elements ?? extra?.elements) as
|
||||
| (import('../types').MessageElementUnion | Record<string, unknown>)[]
|
||||
| undefined
|
||||
const tc: ToolCall = {
|
||||
name: event.name as string,
|
||||
description: event.description as string | undefined,
|
||||
@@ -274,10 +282,29 @@ function handleStoreEvent(
|
||||
args: event.args as Record<string, unknown> | undefined,
|
||||
formatted_result: event.formatted_result as string | undefined,
|
||||
result: event.result,
|
||||
surface_id: (event.surface_id ?? extra?.surface_id) as string | undefined,
|
||||
a2ui: a2uiRaw?.length
|
||||
? { messages: a2uiRaw, surface_id: (event.surface_id ?? extra?.surface_id) as string | undefined }
|
||||
: undefined,
|
||||
elements: mediaEls,
|
||||
}
|
||||
if (tc.tool_call_id) toolCallMap.set(tc.tool_call_id, tc)
|
||||
const all = buildToolCallList(entry.state.toolCalls, tc)
|
||||
update(sessionId, { toolCalls: all })
|
||||
const pendingElements = mediaEls?.length
|
||||
? [...entry.state.pendingElements, ...mediaEls]
|
||||
: entry.state.pendingElements
|
||||
update(sessionId, { toolCalls: all, pendingElements })
|
||||
break
|
||||
}
|
||||
|
||||
case 'message_element': {
|
||||
const extra = event.extra_data as Record<string, unknown> | undefined
|
||||
const el = (event.element ?? extra?.element) as Record<string, unknown> | undefined
|
||||
if (el) {
|
||||
update(sessionId, {
|
||||
pendingElements: [...entry.state.pendingElements, el],
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
@@ -293,6 +320,22 @@ function handleStoreEvent(
|
||||
break
|
||||
}
|
||||
|
||||
case 'approval_required': {
|
||||
// Handle approval request - trigger modal via global event
|
||||
const approval = {
|
||||
id: event.id as string,
|
||||
tool_name: event.tool_name as string,
|
||||
arguments: event.arguments as Record<string, unknown> || {},
|
||||
risk_level: event.risk_level as string || 'medium',
|
||||
agent_name: event.agent_name as string,
|
||||
description: event.description as string,
|
||||
session_id: sessionId,
|
||||
}
|
||||
// Dispatch custom event for approval modal
|
||||
window.dispatchEvent(new CustomEvent('approval-required', { detail: approval }))
|
||||
break
|
||||
}
|
||||
|
||||
case 'end':
|
||||
case 'done':
|
||||
case 'run_cancelled':
|
||||
|
||||
@@ -27,6 +27,7 @@ interface UseSSEReturn {
|
||||
currentResponse: string
|
||||
toolCalls: import('../types').ToolCall[]
|
||||
thinkingSteps: string[]
|
||||
pendingElements: (import('../types').MessageElementUnion | Record<string, unknown>)[]
|
||||
}
|
||||
|
||||
export function useSSE(options: UseSSEOptions = {}): UseSSEReturn {
|
||||
@@ -75,6 +76,7 @@ export function useSSE(options: UseSSEOptions = {}): UseSSEReturn {
|
||||
currentResponse: snap.currentResponse,
|
||||
toolCalls: snap.toolCalls,
|
||||
thinkingSteps: snap.thinkingSteps,
|
||||
pendingElements: snap.pendingElements,
|
||||
sendMessage,
|
||||
cancel,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useEffect, useMemo, useState, type ComponentType } from 'react'
|
||||
import { ComponentRenderer } from '../components/ComponentRenderer'
|
||||
import { a2uiToComponents } from './a2uiToComponents'
|
||||
import type { SurfaceHostProps } from './types'
|
||||
|
||||
interface A2uiRendererProps {
|
||||
messages: Record<string, unknown>[]
|
||||
surfaceId?: string
|
||||
sessionId?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
type SurfaceEntry = {
|
||||
id: string
|
||||
surface: unknown
|
||||
processor: unknown
|
||||
A2uiSurface: ComponentType<{
|
||||
surface: unknown
|
||||
processor: unknown
|
||||
onAction?: (action: Record<string, unknown>) => void
|
||||
}>
|
||||
}
|
||||
|
||||
export function A2uiRenderer({ messages, surfaceId, sessionId, className = '' }: A2uiRendererProps) {
|
||||
const [surfaces, setSurfaces] = useState<SurfaceEntry[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [ready, setReady] = useState(false)
|
||||
|
||||
const messageKey = useMemo(() => JSON.stringify(messages), [messages])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function load() {
|
||||
setError(null)
|
||||
try {
|
||||
const webCore = await import(/* @vite-ignore */ '@a2ui/web_core/v0_9')
|
||||
const reactMod = await import(/* @vite-ignore */ '@a2ui/react/v0_9')
|
||||
const { MessageProcessor } = webCore
|
||||
const { A2uiSurface, basicCatalog } = reactMod
|
||||
|
||||
const processor = new MessageProcessor([basicCatalog])
|
||||
processor.processMessages(messages)
|
||||
|
||||
const rawSurfaces = (processor as { getSurfaces?: () => unknown[] }).getSurfaces?.()
|
||||
?? (processor as { surfaces?: unknown[] }).surfaces
|
||||
?? []
|
||||
|
||||
const list: SurfaceEntry[] = []
|
||||
if (Array.isArray(rawSurfaces) && rawSurfaces.length) {
|
||||
for (const s of rawSurfaces) {
|
||||
const id = typeof s === 'string' ? s : (s as { id?: string })?.id ?? 'main'
|
||||
list.push({ id, surface: s, A2uiSurface, processor })
|
||||
}
|
||||
} else {
|
||||
list.push({
|
||||
id: surfaceId ?? 'main',
|
||||
surface: surfaceId ?? 'main',
|
||||
A2uiSurface,
|
||||
processor,
|
||||
})
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setSurfaces(list)
|
||||
setReady(true)
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: 'A2UI renderer unavailable — install @a2ui/react and @a2ui/web_core',
|
||||
)
|
||||
setReady(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (messages?.length) {
|
||||
load()
|
||||
}
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [messageKey, surfaceId, messages])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={`rounded-md border border-dashed p-3 text-xs text-muted-foreground ${className}`}>
|
||||
<p className="font-medium mb-1">A2UI surface</p>
|
||||
<p>{error}</p>
|
||||
<pre className="mt-2 max-h-40 overflow-auto text-[10px] opacity-70">
|
||||
{JSON.stringify(messages, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!ready || !surfaces.length) {
|
||||
return (
|
||||
<div className={`rounded-md border bg-muted/30 p-3 text-xs text-muted-foreground ${className}`}>
|
||||
Loading A2UI surface…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-2 ${className}`}>
|
||||
{surfaces.map((entry) => (
|
||||
<entry.A2uiSurface
|
||||
key={entry.id}
|
||||
surface={entry.surface}
|
||||
processor={entry.processor}
|
||||
onAction={async (action) => {
|
||||
const sid = surfaceId ?? entry.id
|
||||
await fetch(`/api/surfaces/${sid}/action`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...action, session_id: sessionId }),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SurfaceHost({
|
||||
mode = 'auto',
|
||||
messages = [],
|
||||
components,
|
||||
surfaceId,
|
||||
sessionId,
|
||||
className = '',
|
||||
}: SurfaceHostProps) {
|
||||
if (components?.length) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<ComponentRenderer components={components} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (mode === 'components' || !messages.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (mode === 'auto') {
|
||||
const mapped = a2uiToComponents(messages)
|
||||
if (mapped?.length) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<ComponentRenderer components={mapped} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<A2uiRenderer
|
||||
messages={messages}
|
||||
surfaceId={surfaceId}
|
||||
sessionId={sessionId}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
/** Optional A2UI packages — installed via npm when using agent-driven surfaces. */
|
||||
declare module '@a2ui/web_core/v0_9' {
|
||||
export class MessageProcessor {
|
||||
constructor(catalogs: unknown[])
|
||||
processMessages(messages: unknown[]): void
|
||||
getSurfaces?(): unknown[]
|
||||
surfaces?: unknown[]
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@a2ui/react/v0_9' {
|
||||
import type { ComponentType } from 'react'
|
||||
export const basicCatalog: unknown
|
||||
export const A2uiSurface: ComponentType<{
|
||||
surface: unknown
|
||||
processor: unknown
|
||||
onAction?: (action: Record<string, unknown>) => void
|
||||
}>
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { ComponentDict } from '../components/ComponentRenderer'
|
||||
|
||||
/** Partial A2UI → aiui component mapper (DRY fallback before Google renderer). */
|
||||
|
||||
function textFromNode(node: Record<string, unknown>): string {
|
||||
const text = node.text
|
||||
if (typeof text === 'string') return text
|
||||
if (typeof text === 'object' && text !== null) {
|
||||
const t = text as Record<string, unknown>
|
||||
return String(t.literal ?? t.path ?? '')
|
||||
}
|
||||
return String(node.content ?? node.label ?? '')
|
||||
}
|
||||
|
||||
function mapComponent(comp: Record<string, unknown>): ComponentDict | null {
|
||||
const type = comp.component as string | undefined
|
||||
if (!type) return null
|
||||
|
||||
switch (type) {
|
||||
case 'Text':
|
||||
return { type: 'text', content: textFromNode(comp) }
|
||||
case 'Markdown':
|
||||
return { type: 'markdown', content: textFromNode(comp) }
|
||||
case 'Divider':
|
||||
return { type: 'separator' }
|
||||
case 'Image': {
|
||||
const url = (comp.url as Record<string, unknown>)?.literal ?? comp.url
|
||||
return { type: 'image', src: String(url ?? ''), alt: textFromNode(comp) }
|
||||
}
|
||||
case 'Button':
|
||||
return {
|
||||
type: 'button',
|
||||
label: textFromNode(comp),
|
||||
variant: 'default',
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function a2uiToComponents(messages: Record<string, unknown>[]): ComponentDict[] | null {
|
||||
const out: ComponentDict[] = []
|
||||
let sawUnmapped = false
|
||||
|
||||
for (const msg of messages) {
|
||||
const update = msg.updateComponents as Record<string, unknown> | undefined
|
||||
const components = (update?.components ?? update?.componentList) as Record<string, unknown>[] | undefined
|
||||
if (!Array.isArray(components)) continue
|
||||
|
||||
for (const comp of components) {
|
||||
const mapped = mapComponent(comp)
|
||||
if (mapped) {
|
||||
out.push(mapped)
|
||||
} else {
|
||||
sawUnmapped = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!out.length) return null
|
||||
if (sawUnmapped) return null
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export type SurfaceRenderMode = 'auto' | 'a2ui' | 'components'
|
||||
|
||||
export interface A2uiPayload {
|
||||
messages: Record<string, unknown>[]
|
||||
surface_id?: string
|
||||
}
|
||||
|
||||
export interface SurfaceHostProps {
|
||||
mode?: SurfaceRenderMode
|
||||
messages?: Record<string, unknown>[]
|
||||
components?: import('../components/ComponentRenderer').ComponentDict[]
|
||||
surfaceId?: string
|
||||
sessionId?: string
|
||||
className?: string
|
||||
}
|
||||
@@ -20,6 +20,33 @@ export interface SiteConfig {
|
||||
theme?: ThemeConfig
|
||||
}
|
||||
|
||||
export interface SEOConfig {
|
||||
titleTemplate?: string
|
||||
defaultImage?: string
|
||||
twitter?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface I18nConfig {
|
||||
defaultLocale?: string
|
||||
locales?: string[]
|
||||
rtlLocales?: string[]
|
||||
fallbackLocale?: string
|
||||
translationsDir?: string
|
||||
}
|
||||
|
||||
export interface A11yConfig {
|
||||
skipToContent?: boolean
|
||||
focusVisible?: boolean
|
||||
reduceMotion?: boolean
|
||||
ariaLabels?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface LogoConfig {
|
||||
light?: string
|
||||
dark?: string
|
||||
href?: string
|
||||
}
|
||||
|
||||
export interface WidgetConfig {
|
||||
type: string
|
||||
props?: Record<string, unknown>
|
||||
@@ -58,6 +85,10 @@ export interface UIConfig {
|
||||
widgets?: InputWidget[]
|
||||
components?: Record<string, { props?: Record<string, unknown> }>
|
||||
templates?: Record<string, TemplateConfig>
|
||||
seo?: SEOConfig
|
||||
i18n?: I18nConfig
|
||||
a11y?: A11yConfig
|
||||
logo?: LogoConfig
|
||||
}
|
||||
|
||||
export interface DocsNav {
|
||||
@@ -160,6 +191,11 @@ export interface ToolCall {
|
||||
args?: Record<string, unknown>
|
||||
result?: unknown
|
||||
error?: string
|
||||
a2ui?: { messages: Record<string, unknown>[]; surface_id?: string }
|
||||
surface_id?: string
|
||||
elements?: (MessageElementUnion | Record<string, unknown>)[]
|
||||
capability?: 'stub' | 'live'
|
||||
warning?: string
|
||||
}
|
||||
|
||||
export interface FileAttachment {
|
||||
@@ -260,6 +296,7 @@ export type RunEventType =
|
||||
| 'memory_update_started'
|
||||
| 'memory_update_completed'
|
||||
| 'updating_memory'
|
||||
| 'message_element'
|
||||
// Team events (11 types)
|
||||
| 'team_run_started'
|
||||
| 'team_run_content'
|
||||
@@ -335,10 +372,15 @@ export interface SSEEvent {
|
||||
team_id?: string
|
||||
// Memory
|
||||
memory_type?: 'short_term' | 'long_term'
|
||||
// Message elements (protocol-driven media)
|
||||
element?: MessageElementUnion | Record<string, unknown>
|
||||
elements?: (MessageElementUnion | Record<string, unknown>)[]
|
||||
// Extra data (Agent-UI pattern)
|
||||
extra_data?: {
|
||||
references?: unknown[]
|
||||
reasoning_steps?: string[]
|
||||
element?: MessageElementUnion | Record<string, unknown>
|
||||
elements?: (MessageElementUnion | Record<string, unknown>)[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Button } from '../components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../components/ui/dialog'
|
||||
import { Badge } from '../components/ui/badge'
|
||||
import { Checkbox } from '../components/ui/checkbox'
|
||||
import { Textarea } from '../components/ui/textarea'
|
||||
|
||||
interface Approval {
|
||||
id: string
|
||||
tool_name: string
|
||||
arguments: Record<string, any>
|
||||
risk_level: 'low' | 'medium' | 'high' | 'critical'
|
||||
agent_name?: string
|
||||
description?: string
|
||||
status: 'pending' | 'approved' | 'denied'
|
||||
created_at: number
|
||||
risk_icon?: string
|
||||
}
|
||||
|
||||
const riskColors = {
|
||||
low: 'bg-green-500',
|
||||
medium: 'bg-yellow-500',
|
||||
high: 'bg-orange-500',
|
||||
critical: 'bg-red-500',
|
||||
}
|
||||
|
||||
export function ApprovalsView() {
|
||||
const [approvals, setApprovals] = useState<Approval[]>([])
|
||||
const [activeApproval, setActiveApproval] = useState<Approval | null>(null)
|
||||
const [reason, setReason] = useState('')
|
||||
const [alwaysAllow, setAlwaysAllow] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchApprovals()
|
||||
const eventSource = setupSSE()
|
||||
return () => eventSource?.close()
|
||||
}, [])
|
||||
|
||||
const fetchApprovals = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/approvals?status=pending')
|
||||
const data = await res.json()
|
||||
setApprovals(data.approvals || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch approvals:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const setupSSE = () => {
|
||||
try {
|
||||
const eventSource = new EventSource('/api/approvals/stream')
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
if (data.event === 'new' || data.event === 'resolved') {
|
||||
fetchApprovals()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('SSE parse error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = () => {
|
||||
console.error('SSE connection error')
|
||||
}
|
||||
|
||||
return eventSource
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const handleApprove = async () => {
|
||||
if (!activeApproval) return
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/approvals/${activeApproval.id}/approve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
reason,
|
||||
approver: 'user',
|
||||
always: alwaysAllow,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setActiveApproval(null)
|
||||
setReason('')
|
||||
setAlwaysAllow(false)
|
||||
fetchApprovals()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to approve:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeny = async () => {
|
||||
if (!activeApproval) return
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/approvals/${activeApproval.id}/deny`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
reason,
|
||||
approver: 'user',
|
||||
always: alwaysAllow,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setActiveApproval(null)
|
||||
setReason('')
|
||||
setAlwaysAllow(false)
|
||||
fetchApprovals()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to deny:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold mb-2">Tool Execution Approvals</h1>
|
||||
<p className="text-gray-600">Review and approve tool execution requests</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{approvals.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="text-center py-8">
|
||||
<p className="text-gray-500">No pending approvals</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
approvals.map((approval) => (
|
||||
<Card key={approval.id} className="cursor-pointer hover:shadow-lg transition-shadow"
|
||||
onClick={() => setActiveApproval(approval)}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<span>{approval.risk_icon || '⚠️'}</span>
|
||||
{approval.tool_name}
|
||||
</CardTitle>
|
||||
<Badge className={riskColors[approval.risk_level]}>
|
||||
{approval.risk_level}
|
||||
</Badge>
|
||||
</div>
|
||||
{approval.agent_name && (
|
||||
<CardDescription>Agent: {approval.agent_name}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
{approval.description || 'Tool execution request'}
|
||||
</p>
|
||||
{Object.keys(approval.arguments).length > 0 && (
|
||||
<div className="text-xs bg-gray-100 p-2 rounded">
|
||||
<pre>{JSON.stringify(approval.arguments, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={!!activeApproval} onOpenChange={(open) => !open && setActiveApproval(null)}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{activeApproval?.risk_icon || '⚠️'} Approve Tool Execution?
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{activeApproval?.tool_name} requested by {activeApproval?.agent_name || 'unknown agent'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="my-4">
|
||||
<div className="mb-3">
|
||||
<Badge className={activeApproval ? riskColors[activeApproval.risk_level] : ''}>
|
||||
Risk Level: {activeApproval?.risk_level}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{activeApproval?.description && (
|
||||
<p className="text-sm mb-3">{activeApproval.description}</p>
|
||||
)}
|
||||
|
||||
{activeApproval && Object.keys(activeApproval.arguments).length > 0 && (
|
||||
<div className="bg-gray-100 p-3 rounded mb-3">
|
||||
<p className="text-sm font-semibold mb-1">Arguments:</p>
|
||||
<pre className="text-xs">{JSON.stringify(activeApproval.arguments, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
placeholder="Optional: Provide a reason for your decision..."
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
className="mb-3"
|
||||
/>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="always"
|
||||
checked={alwaysAllow}
|
||||
onCheckedChange={(checked) => setAlwaysAllow(checked as boolean)}
|
||||
/>
|
||||
<label htmlFor="always" className="text-sm">
|
||||
Always {activeApproval?.status === 'pending' ? 'allow' : 'deny'} this tool
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setActiveApproval(null)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDeny} disabled={loading}>
|
||||
Deny
|
||||
</Button>
|
||||
<Button onClick={handleApprove} disabled={loading}>
|
||||
Approve
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { DashboardPageProps } from '../types/dashboard'
|
||||
import { SurfaceHost } from '../surfaces/SurfaceHost'
|
||||
|
||||
/**
|
||||
* Full-page A2UI canvas — live surface backed by /api/surfaces/{id}.
|
||||
*/
|
||||
export function CanvasView({ page }: DashboardPageProps) {
|
||||
const params = new URLSearchParams(typeof window !== 'undefined' ? window.location.search : '')
|
||||
const surfaceId = params.get('surface') ?? (page as { surface_id?: string }).surface_id ?? 'main'
|
||||
const [messages, setMessages] = useState<Record<string, unknown>[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const loadSurface = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await fetch(`/api/surfaces/${encodeURIComponent(surfaceId)}`)
|
||||
if (res.status === 404) {
|
||||
setMessages([])
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
setMessages((data.messages ?? []) as Record<string, unknown>[])
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to load surface'
|
||||
if (msg.includes('404')) {
|
||||
setMessages([])
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
setError(msg)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [surfaceId])
|
||||
|
||||
useEffect(() => {
|
||||
loadSurface()
|
||||
}, [loadSurface])
|
||||
|
||||
useEffect(() => {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const ws = new WebSocket(`${proto}://${window.location.host}/api/chat/ws`)
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const data = JSON.parse(ev.data)
|
||||
if (data.type === 'a2ui_surface' && data.surface_id === surfaceId && data.messages) {
|
||||
setMessages(data.messages as Record<string, unknown>[])
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return () => ws.close()
|
||||
}, [surfaceId])
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-6 text-muted-foreground">Loading canvas…</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold">{page.title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadSurface}
|
||||
className="px-3 py-1.5 text-sm rounded-md border hover:bg-accent"
|
||||
>
|
||||
↻ Refresh
|
||||
</button>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="text-destructive text-sm mb-4">{error}</div>
|
||||
) : null}
|
||||
<SurfaceHost mode="auto" messages={messages} surfaceId={surfaceId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { DashboardPageProps } from '../types/dashboard'
|
||||
import { ComponentRenderer, type ComponentDict } from '../components/ComponentRenderer'
|
||||
import { SurfaceHost } from '../surfaces/SurfaceHost'
|
||||
|
||||
interface KeyValue {
|
||||
label: string
|
||||
@@ -45,6 +46,23 @@ export function CustomPageView({ page }: DashboardPageProps) {
|
||||
if (error) return <div className="p-6 text-destructive">Error: {error}</div>
|
||||
if (!data) return null
|
||||
|
||||
const surfacePayload = data._surface as Record<string, unknown> | undefined
|
||||
if (surfacePayload && (surfacePayload.messages || surfacePayload.id)) {
|
||||
const surfaceId = String(surfacePayload.id ?? 'main')
|
||||
const messages = (surfacePayload.messages ?? []) as Record<string, unknown>[]
|
||||
return (
|
||||
<div className="p-6 max-w-5xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold">{page.title}</h2>
|
||||
<button onClick={fetchData} className="px-3 py-1.5 text-sm rounded-md border hover:bg-accent">
|
||||
↻ Refresh
|
||||
</button>
|
||||
</div>
|
||||
<SurfaceHost mode="auto" messages={messages} surfaceId={surfaceId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (data._components && Array.isArray(data._components)) {
|
||||
return (
|
||||
<div className="p-6 max-w-5xl">
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Button } from '../components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../components/ui/dialog'
|
||||
import { Input } from '../components/ui/input'
|
||||
import { Label } from '../components/ui/label'
|
||||
import { Badge } from '../components/ui/badge'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../components/ui/select'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../components/ui/tabs'
|
||||
|
||||
interface MCPServer {
|
||||
name: string
|
||||
transport: 'stdio' | 'sse' | 'http'
|
||||
status: 'connecting' | 'connected' | 'error' | 'disconnected'
|
||||
tools: Array<{
|
||||
name: string
|
||||
description: string
|
||||
input_schema: Record<string, any>
|
||||
}>
|
||||
last_error?: string
|
||||
connection_data?: Record<string, any>
|
||||
}
|
||||
|
||||
interface NewServerForm {
|
||||
name: string
|
||||
transport: 'stdio' | 'sse' | 'http'
|
||||
command?: string
|
||||
args?: string
|
||||
url?: string
|
||||
headers?: string
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
connected: 'bg-green-500',
|
||||
connecting: 'bg-yellow-500',
|
||||
error: 'bg-red-500',
|
||||
disconnected: 'bg-gray-500',
|
||||
}
|
||||
|
||||
export function McpView() {
|
||||
const [servers, setServers] = useState<MCPServer[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showAddDialog, setShowAddDialog] = useState(false)
|
||||
const [selectedServer, setSelectedServer] = useState<MCPServer | null>(null)
|
||||
const [form, setForm] = useState<NewServerForm>({
|
||||
name: '',
|
||||
transport: 'stdio',
|
||||
command: '',
|
||||
args: '',
|
||||
url: '',
|
||||
headers: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetchServers()
|
||||
const interval = setInterval(fetchServers, 5000) // Poll every 5s
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const fetchServers = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/mcp/servers')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setServers(data.servers || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch MCP servers:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConnect = async () => {
|
||||
setLoading(true)
|
||||
|
||||
const config: any = {
|
||||
name: form.name,
|
||||
}
|
||||
|
||||
if (form.transport === 'stdio') {
|
||||
config.command = form.command
|
||||
if (form.args) {
|
||||
config.args = form.args.split(' ').filter(Boolean)
|
||||
}
|
||||
} else if (form.transport === 'sse' || form.transport === 'http') {
|
||||
config.url = form.url
|
||||
if (form.headers) {
|
||||
try {
|
||||
config.headers = JSON.parse(form.headers)
|
||||
} catch {
|
||||
alert('Invalid JSON for headers')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/mcp/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setShowAddDialog(false)
|
||||
setForm({
|
||||
name: '',
|
||||
transport: 'stdio',
|
||||
command: '',
|
||||
args: '',
|
||||
url: '',
|
||||
headers: '',
|
||||
})
|
||||
fetchServers()
|
||||
} else {
|
||||
const error = await res.text()
|
||||
alert(`Failed to connect: ${error}`)
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Connection error: ${err}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisconnect = async (serverName: string) => {
|
||||
if (!confirm(`Disconnect from ${serverName}?`)) return
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/mcp/disconnect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: serverName }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
fetchServers()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to disconnect:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestTool = async (serverName: string, toolName: string) => {
|
||||
try {
|
||||
const res = await fetch('/api/mcp/test-tool', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ server: serverName, tool: toolName }),
|
||||
})
|
||||
|
||||
const result = await res.json()
|
||||
alert(`Tool test result:\n${JSON.stringify(result, null, 2)}`)
|
||||
} catch (err) {
|
||||
alert(`Failed to test tool: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4">
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-2">MCP Manager</h1>
|
||||
<p className="text-gray-600">Manage Model Context Protocol server connections</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowAddDialog(true)}>
|
||||
+ Connect Server
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{servers.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="text-center py-8">
|
||||
<p className="text-gray-500">No MCP servers configured</p>
|
||||
<Button className="mt-4" onClick={() => setShowAddDialog(true)}>
|
||||
Connect Your First Server
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
servers.map((server) => (
|
||||
<Card key={server.name}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle>{server.name}</CardTitle>
|
||||
<Badge className={statusColors[server.status]}>
|
||||
{server.status}
|
||||
</Badge>
|
||||
<Badge variant="outline">{server.transport}</Badge>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={server.status === 'connected' ? 'destructive' : 'default'}
|
||||
onClick={() =>
|
||||
server.status === 'connected'
|
||||
? handleDisconnect(server.name)
|
||||
: handleConnect()
|
||||
}
|
||||
>
|
||||
{server.status === 'connected' ? 'Disconnect' : 'Reconnect'}
|
||||
</Button>
|
||||
</div>
|
||||
{server.last_error && (
|
||||
<CardDescription className="text-red-500 mt-2">
|
||||
Error: {server.last_error}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="tools">
|
||||
<TabsList>
|
||||
<TabsTrigger value="tools">Tools ({server.tools.length})</TabsTrigger>
|
||||
<TabsTrigger value="config">Configuration</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="tools" className="mt-4">
|
||||
{server.tools.length === 0 ? (
|
||||
<p className="text-gray-500">No tools available</p>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{server.tools.map((tool) => (
|
||||
<div key={tool.name} className="border rounded p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-semibold">{tool.name}</p>
|
||||
<p className="text-sm text-gray-600">{tool.description}</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleTestTool(server.name, tool.name)}
|
||||
>
|
||||
Test
|
||||
</Button>
|
||||
</div>
|
||||
{Object.keys(tool.input_schema).length > 0 && (
|
||||
<details className="mt-2">
|
||||
<summary className="text-sm text-gray-500 cursor-pointer">
|
||||
Schema
|
||||
</summary>
|
||||
<pre className="text-xs mt-1 bg-gray-100 p-2 rounded overflow-auto">
|
||||
{JSON.stringify(tool.input_schema, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="config" className="mt-4">
|
||||
<div className="bg-gray-100 p-3 rounded">
|
||||
<pre className="text-sm">
|
||||
{JSON.stringify(server.connection_data || {}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connect MCP Server</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure a new Model Context Protocol server connection
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Server Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="my-mcp-server"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="transport">Transport Type</Label>
|
||||
<Select
|
||||
value={form.transport}
|
||||
onValueChange={(value) => setForm({ ...form, transport: value as any })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="stdio">STDIO (Subprocess)</SelectItem>
|
||||
<SelectItem value="sse">SSE (Server-Sent Events)</SelectItem>
|
||||
<SelectItem value="http">HTTP (REST API)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{form.transport === 'stdio' && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="command">Command</Label>
|
||||
<Input
|
||||
id="command"
|
||||
value={form.command}
|
||||
onChange={(e) => setForm({ ...form, command: e.target.value })}
|
||||
placeholder="npx"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="args">Arguments (space-separated)</Label>
|
||||
<Input
|
||||
id="args"
|
||||
value={form.args}
|
||||
onChange={(e) => setForm({ ...form, args: e.target.value })}
|
||||
placeholder="-y @modelcontextprotocol/server-filesystem"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(form.transport === 'sse' || form.transport === 'http') && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="url">Server URL</Label>
|
||||
<Input
|
||||
id="url"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm({ ...form, url: e.target.value })}
|
||||
placeholder="http://localhost:3000/sse"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="headers">Headers (JSON)</Label>
|
||||
<Input
|
||||
id="headers"
|
||||
value={form.headers}
|
||||
onChange={(e) => setForm({ ...form, headers: e.target.value })}
|
||||
placeholder='{"Authorization": "Bearer token"}'
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowAddDialog(false)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConnect} disabled={loading || !form.name}>
|
||||
{loading ? 'Connecting...' : 'Connect'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { UsageView } from './UsageView'
|
||||
import { DebugView } from './DebugView'
|
||||
import { RealtimeView } from './RealtimeView'
|
||||
import { CustomPageView } from './CustomPageView'
|
||||
import { CanvasView } from './CanvasView'
|
||||
|
||||
/** Registry of built-in page components, keyed by page ID */
|
||||
export const BUILTIN_VIEWS: Record<string, ComponentType<DashboardPageProps>> = {
|
||||
@@ -30,6 +31,7 @@ export const BUILTIN_VIEWS: Record<string, ComponentType<DashboardPageProps>> =
|
||||
usage: UsageView as unknown as ComponentType<DashboardPageProps>,
|
||||
debug: DebugView as unknown as ComponentType<DashboardPageProps>,
|
||||
realtime: RealtimeView as unknown as ComponentType<DashboardPageProps>,
|
||||
canvas: CanvasView as unknown as ComponentType<DashboardPageProps>,
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
],
|
||||
"module": "ESNext",
|
||||
"types": [
|
||||
"vite/client"
|
||||
"vite/client",
|
||||
"node"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
@@ -31,6 +31,8 @@ def __getattr__(name: str):
|
||||
"on",
|
||||
"resume",
|
||||
"page",
|
||||
"surface_page",
|
||||
"surface_action",
|
||||
"say",
|
||||
"stream",
|
||||
"stream_token",
|
||||
@@ -171,6 +173,7 @@ def __getattr__(name: str):
|
||||
"register_page",
|
||||
"set_datastore",
|
||||
"get_datastore",
|
||||
"SessionGovernor",
|
||||
"set_provider",
|
||||
"get_provider",
|
||||
"set_style",
|
||||
@@ -182,8 +185,13 @@ def __getattr__(name: str):
|
||||
"set_custom_js",
|
||||
"register_theme",
|
||||
"set_chat_features",
|
||||
"set_settings",
|
||||
"set_dashboard",
|
||||
"set_jobs_api",
|
||||
"set_jobs_backend",
|
||||
"set_jobs_proxy",
|
||||
"set_chat_mode",
|
||||
"set_chat_preview",
|
||||
"set_brand_color",
|
||||
"set_sidebar_config",
|
||||
"set_feedback_enabled",
|
||||
@@ -364,6 +372,9 @@ def __getattr__(name: str):
|
||||
|
||||
return getattr(chat_settings, name)
|
||||
if name in _server_attrs:
|
||||
if name == "SessionGovernor":
|
||||
from praisonaiui.session_governor import SessionGovernor
|
||||
return SessionGovernor
|
||||
from praisonaiui import server
|
||||
|
||||
return getattr(server, name)
|
||||
@@ -434,6 +445,8 @@ __all__ = [
|
||||
"on",
|
||||
"resume",
|
||||
"page",
|
||||
"surface_page",
|
||||
"surface_action",
|
||||
# Lifecycle hooks
|
||||
"on_app_startup",
|
||||
"on_app_shutdown",
|
||||
@@ -461,6 +474,7 @@ __all__ = [
|
||||
"register_page",
|
||||
"set_datastore",
|
||||
"get_datastore",
|
||||
"SessionGovernor",
|
||||
"set_provider",
|
||||
"get_provider",
|
||||
"set_style",
|
||||
@@ -468,8 +482,13 @@ __all__ = [
|
||||
"set_theme",
|
||||
"set_custom_css",
|
||||
"set_chat_features",
|
||||
"set_settings",
|
||||
"set_dashboard",
|
||||
"set_jobs_api",
|
||||
"set_jobs_backend",
|
||||
"set_jobs_proxy",
|
||||
"set_chat_mode",
|
||||
"set_chat_preview",
|
||||
"set_brand_color",
|
||||
"set_sidebar_config",
|
||||
"set_feedback_enabled",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Default SDK hooks lister — used when no backend injected."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
def list_sdk_hooks() -> List[Dict[str, Any]]:
|
||||
"""Flat hook list from praisonaiagents HookRegistry."""
|
||||
try:
|
||||
from praisonaiagents.hooks import get_default_registry
|
||||
|
||||
registry = get_default_registry()
|
||||
flat: List[Dict[str, Any]] = []
|
||||
for event, hooks in registry.list_hooks().items():
|
||||
for h in hooks:
|
||||
flat.append({"event": event, "source": "sdk", **h})
|
||||
return flat
|
||||
except ImportError:
|
||||
return []
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Default workflow runner — AgentFlow from registered workflow steps."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
def run_workflow(
|
||||
workflow_id: str,
|
||||
*,
|
||||
workflow: Dict[str, Any],
|
||||
input_data: Dict[str, Any] | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Run workflow via praisonaiagents AgentFlow when steps are defined."""
|
||||
run_id = uuid.uuid4().hex[:12]
|
||||
started = time.time()
|
||||
input_data = input_data or {}
|
||||
text = input_data.get("text") or input_data.get("message") or str(input_data)
|
||||
|
||||
try:
|
||||
from praisonaiagents import Agent, AgentFlow
|
||||
|
||||
raw_steps = workflow.get("steps") or []
|
||||
if not raw_steps:
|
||||
return {
|
||||
"id": run_id,
|
||||
"workflow_id": workflow_id,
|
||||
"workflow_name": workflow.get("name", ""),
|
||||
"status": "failed",
|
||||
"input": input_data,
|
||||
"error": "Workflow has no steps",
|
||||
"started_at": started,
|
||||
"completed_at": time.time(),
|
||||
}
|
||||
|
||||
steps = []
|
||||
for i, step in enumerate(raw_steps):
|
||||
if isinstance(step, str):
|
||||
steps.append(
|
||||
Agent(
|
||||
name=f"step_{i + 1}",
|
||||
instructions=step,
|
||||
llm=workflow.get("model", "gpt-4o-mini"),
|
||||
)
|
||||
)
|
||||
else:
|
||||
steps.append(step)
|
||||
|
||||
flow = AgentFlow(steps=steps, name=workflow.get("name", "Workflow"))
|
||||
result = flow.run(text)
|
||||
return {
|
||||
"id": run_id,
|
||||
"workflow_id": workflow_id,
|
||||
"workflow_name": workflow.get("name", ""),
|
||||
"status": "completed",
|
||||
"input": input_data,
|
||||
"output": result if isinstance(result, dict) else {"result": str(result)},
|
||||
"started_at": started,
|
||||
"completed_at": time.time(),
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"id": run_id,
|
||||
"workflow_id": workflow_id,
|
||||
"workflow_name": workflow.get("name", ""),
|
||||
"status": "failed",
|
||||
"input": input_data,
|
||||
"error": str(exc),
|
||||
"started_at": started,
|
||||
"completed_at": time.time(),
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"""A2UI payload detection and normalisation for PraisonAIUI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE
|
||||
|
||||
DEFAULT_SURFACE_ID = "main"
|
||||
A2UI_VERSION = "v0.9"
|
||||
|
||||
|
||||
def is_a2ui_tool_result(result: Any) -> bool:
|
||||
"""Return True if *result* looks like an A2UI tool or A2A part payload."""
|
||||
if result is None:
|
||||
return False
|
||||
if isinstance(result, dict):
|
||||
mime = result.get("mime_type") or result.get("mimeType")
|
||||
if mime == A2UI_MIME_TYPE:
|
||||
return True
|
||||
if isinstance(result.get("messages"), list):
|
||||
return True
|
||||
if isinstance(result.get("a2ui_part"), (dict, list)):
|
||||
return True
|
||||
if isinstance(result, list) and result:
|
||||
first = result[0]
|
||||
if isinstance(first, dict) and any(
|
||||
k in first for k in ("createSurface", "updateComponents", "updateDataModel", "deleteSurface")
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _extract_raw_messages(result: Any) -> List[Dict[str, Any]]:
|
||||
"""Pull the message list from various A2UI result shapes."""
|
||||
if isinstance(result, list):
|
||||
return [m for m in result if isinstance(m, dict)]
|
||||
if not isinstance(result, dict):
|
||||
return []
|
||||
|
||||
for key in ("messages", "a2ui_part"):
|
||||
val = result.get(key)
|
||||
if isinstance(val, list):
|
||||
return [m for m in val if isinstance(m, dict)]
|
||||
if isinstance(val, dict) and isinstance(val.get("messages"), list):
|
||||
return [m for m in val["messages"] if isinstance(m, dict)]
|
||||
|
||||
if any(k in result for k in ("createSurface", "updateComponents", "updateDataModel", "deleteSurface")):
|
||||
return [result]
|
||||
return []
|
||||
|
||||
|
||||
def coerce_a2ui_tool_messages(raw: Any, *, surface_id: str = DEFAULT_SURFACE_ID) -> List[Dict[str, Any]]:
|
||||
"""Coerce common LLM tool-arg shapes into a valid A2UI message list."""
|
||||
if raw is None:
|
||||
raise ValueError("messages must be a list of A2UI message dicts or a JSON string")
|
||||
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
raw = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"messages JSON string is not valid JSON: {exc}") from exc
|
||||
|
||||
if isinstance(raw, dict):
|
||||
if isinstance(raw.get("messages"), list):
|
||||
raw = raw["messages"]
|
||||
elif "components" in raw:
|
||||
raw = [{"updateComponents": {"components": raw["components"]}}]
|
||||
elif any(k in raw for k in ("createSurface", "updateComponents", "updateDataModel", "deleteSurface")):
|
||||
raw = [raw]
|
||||
elif raw.get("component"):
|
||||
raw = [{"updateComponents": {"components": [raw]}}]
|
||||
else:
|
||||
raise ValueError("messages must be a list of A2UI message dicts or a JSON string")
|
||||
|
||||
if not isinstance(raw, list):
|
||||
raise ValueError("messages must be a list of A2UI message dicts or a JSON string")
|
||||
|
||||
items = [dict(m) for m in raw if isinstance(m, dict)]
|
||||
if not items:
|
||||
raise ValueError("messages must contain at least one A2UI message dict")
|
||||
|
||||
if not any(m.get("createSurface") for m in items):
|
||||
items.insert(0, {"createSurface": {"surfaceId": surface_id, "catalogId": "basic"}})
|
||||
|
||||
return normalise_a2ui_messages({"messages": items})
|
||||
|
||||
|
||||
def normalise_a2ui_messages(result: Any) -> List[Dict[str, Any]]:
|
||||
"""Normalise A2UI messages — ensure version field and list shape."""
|
||||
raw = _extract_raw_messages(result)
|
||||
out: List[Dict[str, Any]] = []
|
||||
for msg in raw:
|
||||
item = dict(msg)
|
||||
if "version" not in item:
|
||||
item["version"] = A2UI_VERSION
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def infer_surface_id(result: Any, messages: Optional[List[Dict[str, Any]]] = None) -> str:
|
||||
"""Infer surface id from createSurface message or result dict."""
|
||||
msgs = messages if messages is not None else normalise_a2ui_messages(result)
|
||||
for msg in msgs:
|
||||
create = msg.get("createSurface")
|
||||
if isinstance(create, dict):
|
||||
sid = create.get("surfaceId") or create.get("id")
|
||||
if sid:
|
||||
return str(sid)
|
||||
if isinstance(result, dict):
|
||||
sid = result.get("surface_id") or result.get("surfaceId")
|
||||
if sid:
|
||||
return str(sid)
|
||||
return DEFAULT_SURFACE_ID
|
||||
|
||||
|
||||
def build_a2ui_extra(result: Any) -> Optional[Dict[str, Any]]:
|
||||
"""Build extra_data dict for RunEvent when result is A2UI."""
|
||||
if not is_a2ui_tool_result(result):
|
||||
return None
|
||||
messages = normalise_a2ui_messages(result)
|
||||
if not messages:
|
||||
return None
|
||||
surface_id = infer_surface_id(result, messages)
|
||||
return {"a2ui": messages, "surface_id": surface_id}
|
||||
|
||||
|
||||
def tool_completed_extra(result: Any, *, has_complete_args: bool = True) -> Dict[str, Any]:
|
||||
"""Merge standard tool-completion flags with optional A2UI and media payloads."""
|
||||
extra: Dict[str, Any] = {}
|
||||
if has_complete_args:
|
||||
extra["has_complete_args"] = True
|
||||
try:
|
||||
from praisonaiui.media_utils import build_media_extra
|
||||
|
||||
media = build_media_extra(result)
|
||||
if media:
|
||||
extra.update(media)
|
||||
except ImportError:
|
||||
pass
|
||||
a2ui_extra = build_a2ui_extra(result)
|
||||
if a2ui_extra:
|
||||
extra.update(a2ui_extra)
|
||||
return extra
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Optional L1 backend injection for PraisonAIUI features.
|
||||
|
||||
Standalone aiui uses built-in defaults. When integrated with PraisonAI,
|
||||
the wrapper calls ``set_backend()`` to supply SDK bridges without coupling
|
||||
aiui to praisonai imports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
_backends: Dict[str, Any] = {}
|
||||
|
||||
|
||||
def set_backend(name: str, impl: Any) -> None:
|
||||
"""Register a backend implementation for a feature area."""
|
||||
_backends[name] = impl
|
||||
|
||||
|
||||
def get_backend(name: str, default: Any = None) -> Any:
|
||||
"""Return a registered backend or *default*."""
|
||||
return _backends.get(name, default)
|
||||
|
||||
|
||||
def clear_backends() -> None:
|
||||
"""Clear all injected backends (tests)."""
|
||||
_backends.clear()
|
||||
|
||||
|
||||
def list_backends() -> List[str]:
|
||||
"""Return names of registered backends."""
|
||||
return list(_backends.keys())
|
||||
|
||||
|
||||
def is_integrated_mode() -> bool:
|
||||
"""True when PraisonAI host has injected SDK bridges."""
|
||||
if os.environ.get("PRAISONAI_INTEGRATED", "").strip().lower() in ("1", "true", "yes"):
|
||||
return True
|
||||
return any(k in _backends for k in ("hooks", "workflows", "usage_query", "usage_sink"))
|
||||
|
||||
|
||||
def get_workflow_runner() -> Optional[Callable[..., Dict[str, Any]]]:
|
||||
"""Return workflow runner if injected."""
|
||||
runner = get_backend("workflows")
|
||||
return runner if callable(runner) else None
|
||||
|
||||
|
||||
def get_hooks_lister() -> Optional[Callable[[], list]]:
|
||||
"""Return SDK hooks lister if injected."""
|
||||
lister = get_backend("hooks")
|
||||
return lister if callable(lister) else None
|
||||
|
||||
|
||||
def get_usage_sink() -> Any:
|
||||
"""Return TokenUsageSinkProtocol implementation if injected."""
|
||||
return get_backend("usage_sink")
|
||||
|
||||
|
||||
def get_usage_query() -> Optional[Callable[..., Any]]:
|
||||
"""Return SDK usage query callable if injected."""
|
||||
query = get_backend("usage_query")
|
||||
return query if callable(query) else None
|
||||
|
||||
|
||||
def get_approvals_lister() -> Optional[Callable[..., List[Dict[str, Any]]]]:
|
||||
"""Return SDK approvals lister callable if injected."""
|
||||
lister = get_backend("approvals")
|
||||
return lister if callable(lister) else None
|
||||
|
||||
|
||||
def get_jobs_store_factory() -> Optional[Callable[[], Any]]:
|
||||
"""Return jobs store factory if injected by praisonai host."""
|
||||
factory = get_backend("jobs_store")
|
||||
return factory if callable(factory) else None
|
||||
|
||||
|
||||
def get_jobs_executor_factory() -> Optional[Callable[[], Any]]:
|
||||
"""Return jobs executor factory if injected by praisonai host."""
|
||||
factory = get_backend("jobs_executor")
|
||||
return factory if callable(factory) else None
|
||||
|
||||
|
||||
def get_channel_bot_factory() -> Optional[Callable[..., Any]]:
|
||||
"""Return channel bot factory(platform, token, agent, config) if injected."""
|
||||
factory = get_backend("channel_bot")
|
||||
return factory if callable(factory) else None
|
||||
|
||||
|
||||
def get_tool_resolver() -> Any:
|
||||
"""Return ToolResolver-like object from backend or praisonai wrapper."""
|
||||
resolver = get_backend("tool_resolver")
|
||||
if resolver is not None:
|
||||
return resolver() if callable(resolver) and not hasattr(resolver, "resolve") else resolver
|
||||
try:
|
||||
from praisonai.tool_resolver import ToolResolver
|
||||
|
||||
return ToolResolver()
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
|
||||
def get_kanban_store_factory() -> Optional[Callable[[], Any]]:
|
||||
"""Return kanban store factory if injected by praisonai host."""
|
||||
factory = get_backend("kanban_store")
|
||||
return factory if callable(factory) else None
|
||||
|
||||
|
||||
def get_kanban_api_base() -> Optional[str]:
|
||||
"""Optional external kanban API base (wrapper mounts its own router)."""
|
||||
base = get_backend("kanban_api_base")
|
||||
return str(base).rstrip("/") if base else None
|
||||
|
||||
|
||||
def resolve_tools(tool_names: List[Any]) -> List[Any]:
|
||||
"""Resolve tool name strings via injected or default ToolResolver."""
|
||||
resolver = get_tool_resolver()
|
||||
if resolver is None:
|
||||
return []
|
||||
resolved: List[Any] = []
|
||||
for name in tool_names:
|
||||
if isinstance(name, str) and name.strip():
|
||||
tool = resolver.resolve(name.strip())
|
||||
if tool is not None:
|
||||
resolved.append(tool)
|
||||
return resolved
|
||||
@@ -326,6 +326,55 @@ def page(
|
||||
return decorator
|
||||
|
||||
|
||||
def surface_page(
|
||||
id: str,
|
||||
*,
|
||||
surface_id: str = "main",
|
||||
title: str = "Canvas",
|
||||
icon: str = "🖼️",
|
||||
group: str = "Custom",
|
||||
description: str = "",
|
||||
order: int = 100,
|
||||
) -> Callable[[F], F]:
|
||||
"""Register a dashboard page backed by an A2UI surface."""
|
||||
|
||||
def decorator(func: F) -> F:
|
||||
from praisonaiui.server import register_page
|
||||
|
||||
async def handler():
|
||||
data = func()
|
||||
if asyncio.iscoroutine(data):
|
||||
data = await data
|
||||
if isinstance(data, dict) and "_surface" in data:
|
||||
return data
|
||||
return {"_surface": {"id": surface_id, "messages": data or []}}
|
||||
|
||||
register_page(
|
||||
id,
|
||||
title=title,
|
||||
icon=icon,
|
||||
group=group,
|
||||
description=description,
|
||||
handler=handler,
|
||||
order=order,
|
||||
)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def surface_action(surface_id: str = "main") -> Callable[[F], F]:
|
||||
"""Decorator to handle user actions from an A2UI surface."""
|
||||
|
||||
def decorator(func: F) -> F:
|
||||
from praisonaiui.features.surfaces import register_surface_action
|
||||
|
||||
register_surface_action(surface_id, func)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def resume(func: F) -> F:
|
||||
"""Decorator for session resume handler.
|
||||
|
||||
|
||||
Loaded 100 of 287 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user