mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 05:56:17 -10:00
Compare commits
65
Commits
No files matched your search
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"templateRepo": "MervinPraison/github-automation-template",
|
||||
"stack": "claude",
|
||||
"profile": "sdk",
|
||||
"mode": "hub",
|
||||
"hubRef": "v1.1.6",
|
||||
"templateVersion": "1.1.6",
|
||||
"installedAt": "2026-09-07T10:02:11Z"
|
||||
}
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
trigger-claude:
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'issues' && !contains(toJSON(github.event.issue.labels), 'claude'))
|
||||
github.event_name == 'issues'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
@@ -52,7 +52,8 @@ jobs:
|
||||
});
|
||||
|
||||
const labels = (issue.labels || []).map(l => typeof l === 'string' ? l : l.name);
|
||||
if (!labels.includes('claude')) {
|
||||
const hadClaude = labels.includes('claude');
|
||||
if (!hadClaude) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
@@ -60,7 +61,7 @@ jobs:
|
||||
labels: ['claude'],
|
||||
});
|
||||
} else {
|
||||
console.log(`Issue #${issueNumber} already has 'claude' label.`);
|
||||
console.log(`Issue #${issueNumber} already has 'claude' label — dispatching Claude only.`);
|
||||
}
|
||||
|
||||
const body = [
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
name: Auto PR Comment
|
||||
|
||||
# Review chain: CodeRabbit/Qodo/Gemini review first → Copilot → Claude (final)
|
||||
# For human PRs: CodeRabbit/Qodo auto-review → this workflow triggers Copilot → then Claude
|
||||
# For bot PRs: CodeRabbit/Qodo skip bots, so we trigger them explicitly first
|
||||
#
|
||||
# IMPORTANT: @copilot ignores bot comments. All @copilot mentions must use
|
||||
# PAT_TOKEN so the comment posts as MervinPraison (a real user).
|
||||
#
|
||||
# Claude trigger runs IN-BAND (same workflow) to avoid GitHub Actions
|
||||
# blocking bot-triggered workflows with action_required approval gates.
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
@@ -17,793 +8,26 @@ on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
pull_request_target:
|
||||
types: [synchronize]
|
||||
types: [opened, synchronize]
|
||||
schedule:
|
||||
- cron: '30 3,9,15,21 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number for manual FINAL (leave empty to sweep all open PRs)'
|
||||
required: false
|
||||
type: number
|
||||
|
||||
env:
|
||||
REVIEW_CHAIN_SKIP_COPILOT: '1'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
MAX_POLL_ATTEMPTS: 20
|
||||
POLL_DELAY_MS: 30000
|
||||
CLAUDE_TRIGGER_LOGINS: '["MervinPraison","github-actions[bot]"]'
|
||||
# GH_TOKEN (user PAT) posts as MervinPraison for @copilot; fallback to github.token for @claude
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
CLAUDE_UI_SCOPE: 'SCOPE: Focus ONLY on PraisonAIUI (src/praisonaiui, src/frontend, tests, docs). Do not expand into praisonaiagents or the monorepo unless the issue explicitly requires it.'
|
||||
CLAUDE_FINAL_PHASES_COMPACT: "**Phase 1: Review per AGENTS.md** (lazy imports, backward compat, import-time <200ms; SDK/product value gate — genuine UI value, correct UI vs praisonaiagents layering, no scope creep)\n**Phase 2: FIX valid issues** found by prior reviewers — push to THIS branch\n**Phase 3: Final verdict** — approve PR if ready, else request changes"
|
||||
CLAUDE_FINAL_PHASES_DETAILED: "**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions (lazy imports, import-time <200ms)\n4. Product value: review in depth whether the change genuinely adds PraisonAIUI UX value — never add features for the sake of adding them. It must strengthen the UI (simpler, more user-friendly, robust). Reject scope creep; request changes or recommend closing if value is unclear\n5. Layering: UI/frontend/server features here; core agent runtime belongs in praisonaiagents. Agent-callable tools → PraisonAI-Tools\n6. Do not bloat server.py or create_app() with new params — prefer existing YAML config, plugins, and feature modules; reject knobs/exports with no live consumer\n\n**Phase 2: FIX Valid Issues**\n7. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n8. Push all code fixes directly to THIS branch (do NOT create a new PR)\n9. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n10. If all issues are resolved, approve the PR / close the Issue\n11. If blocking issues remain, request changes / leave clear action items"
|
||||
|
||||
jobs:
|
||||
# ================================================================
|
||||
# HUMAN PRs: Trigger @copilot AFTER CodeRabbit posts its summary
|
||||
# ================================================================
|
||||
copilot-after-coderabbit:
|
||||
if: |
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
github.event.comment.user.login == 'coderabbitai[bot]' &&
|
||||
contains(github.event.comment.body, 'summarize by coderabbit')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
outputs:
|
||||
pr_number: ${{ steps.trigger.outputs.pr_number }}
|
||||
triggered: ${{ steps.trigger.outputs.triggered }}
|
||||
steps:
|
||||
- name: Post Copilot review request (as user via PAT)
|
||||
id: trigger
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
// Check for duplicates — posted by MervinPraison (PAT) or github-actions[bot] (legacy)
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@copilot') &&
|
||||
(c.user.login === 'MervinPraison' || c.user.login === 'github-actions[bot]')
|
||||
);
|
||||
if (alreadyPosted) {
|
||||
console.log('Copilot already triggered, skipping');
|
||||
core.setOutput('triggered', 'false');
|
||||
core.setOutput('pr_number', context.issue.number.toString());
|
||||
return;
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@copilot Do a thorough review of this PR. Read ALL existing reviewer comments above from Qodo, Coderabbit, and Gemini first — incorporate their findings.\n\nReview areas:\n1. **Bloat check**: Are changes minimal and focused? Any unnecessary code or scope creep?\n2. **Security**: Any hardcoded secrets, unsafe eval/exec, missing input validation?\n3. **Performance**: Any module-level heavy imports? Hot-path regressions?\n4. **Tests**: Are tests included? Do they cover the changes adequately?\n5. **Backward compat**: Any public API changes without deprecation?\n6. **Code quality**: DRY violations, naming conventions, error handling?\n7. **Address reviewer feedback**: If Qodo, Coderabbit, or Gemini flagged valid issues, include them in your review\n8. Suggest specific improvements with code examples where possible'
|
||||
});
|
||||
core.setOutput('triggered', 'true');
|
||||
core.setOutput('pr_number', context.issue.number.toString());
|
||||
|
||||
# Fallback: Trigger @copilot after Qodo review (if Coderabbit doesn't fire)
|
||||
copilot-after-qodo:
|
||||
if: |
|
||||
github.event_name == 'pull_request_review' &&
|
||||
github.event.review.user.login == 'qodo-code-review[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
outputs:
|
||||
pr_number: ${{ steps.trigger.outputs.pr_number }}
|
||||
triggered: ${{ steps.trigger.outputs.triggered }}
|
||||
steps:
|
||||
- name: Post Copilot review request (as user via PAT)
|
||||
id: trigger
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@copilot') &&
|
||||
(c.user.login === 'MervinPraison' || c.user.login === 'github-actions[bot]')
|
||||
);
|
||||
if (alreadyPosted) {
|
||||
console.log('Copilot already triggered, skipping');
|
||||
core.setOutput('triggered', 'false');
|
||||
core.setOutput('pr_number', context.issue.number.toString());
|
||||
return;
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@copilot Do a thorough review of this PR. Read ALL existing reviewer comments above first.\n\nReview areas:\n1. **Bloat check**: Are changes minimal and focused?\n2. **Security**: Any hardcoded secrets, unsafe eval/exec, missing input validation?\n3. **Performance**: Any module-level heavy imports? Hot-path regressions?\n4. **Tests**: Are tests included? Do they cover the changes adequately?\n5. **Backward compat**: Any public API changes without deprecation?\n6. **Code quality**: DRY violations, naming conventions, error handling?\n7. Suggest specific improvements with code examples where possible'
|
||||
});
|
||||
core.setOutput('triggered', 'true');
|
||||
core.setOutput('pr_number', context.issue.number.toString());
|
||||
|
||||
# ================================================================
|
||||
# FALLBACK: Claude trigger for cases where Copilot chain fails/skips
|
||||
# Ensures Claude always runs after sufficient review time
|
||||
# ================================================================
|
||||
claude-fallback-timeout:
|
||||
if: |
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.action == 'opened'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
review-chain:
|
||||
# Grant union of callee job permissions (repos with default_workflow_permissions: read
|
||||
# reject reusable calls when the caller ceiling is lower than any callee job).
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Wait for review window (15 min)
|
||||
run: sleep 900
|
||||
|
||||
- name: Check if Claude already triggered
|
||||
id: check_claude
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
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
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
core.setOutput('comments_count', comments.data.length.toString());
|
||||
|
||||
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: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
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();
|
||||
const body = c.body.substring(0, 500);
|
||||
if (login.includes('coderabbit')) reviews.coderabbit.push(body);
|
||||
else if (login.includes('qodo')) reviews.qodo.push(body);
|
||||
else if (login.includes('gemini')) reviews.gemini.push(body);
|
||||
else if (login.includes('copilot')) reviews.copilot.push(body);
|
||||
else if (login.includes('greptile')) reviews.greptile.push(body);
|
||||
else if (c.user.type === 'Bot') reviews.others.push(body);
|
||||
});
|
||||
const summaryParts = [];
|
||||
if (reviews.coderabbit.length) summaryParts.push('CodeRabbit: ' + reviews.coderabbit.length + ' comments');
|
||||
if (reviews.qodo.length) summaryParts.push('Qodo: ' + reviews.qodo.length + ' comments');
|
||||
if (reviews.gemini.length) summaryParts.push('Gemini: ' + reviews.gemini.length + ' comments');
|
||||
if (reviews.copilot.length) summaryParts.push('Copilot: ' + reviews.copilot.length + ' comments');
|
||||
if (reviews.greptile.length) summaryParts.push('Greptile: ' + reviews.greptile.length + ' comments');
|
||||
const reviewSummary = summaryParts.length ? summaryParts.join(' | ') : 'No bot reviews detected';
|
||||
const scope = process.env.CLAUDE_UI_SCOPE;
|
||||
const phases = process.env.CLAUDE_FINAL_PHASES_COMPACT;
|
||||
const claudeBody = '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAIUI (not a fork), you are able to make modifications to this branch and push directly. ' + scope + ' Read ALL prior reviewer comments. ' + reviewSummary + '.\n\n' +
|
||||
'Review Context: ' + comments.data.length + ' total comments, ' +
|
||||
(reviews.coderabbit.length + reviews.qodo.length + reviews.gemini.length + reviews.copilot.length + reviews.greptile.length) + ' bot reviews.\n\n' +
|
||||
phases;
|
||||
await github.rest.issues.createComment({issue_number: pr, owner: context.repo.owner, repo: context.repo.repo, body: claudeBody});
|
||||
console.log('Claude fallback triggered on PR #' + pr + ' with ' + reviewSummary);
|
||||
|
||||
# ================================================================
|
||||
# Claude FINAL review — polls for Copilot's response, then triggers
|
||||
# This runs IN-BAND under the PAT context (MervinPraison), avoiding
|
||||
# the action_required approval gate that blocks bot-triggered workflows.
|
||||
# ================================================================
|
||||
claude-after-copilot:
|
||||
needs: [copilot-after-coderabbit]
|
||||
if: |
|
||||
always() &&
|
||||
needs.copilot-after-coderabbit.result == 'success' &&
|
||||
needs.copilot-after-coderabbit.outputs.triggered == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Wait for Copilot to respond (poll)
|
||||
id: poll
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
|
||||
MAX_POLL_ATTEMPTS: ${{ env.MAX_POLL_ATTEMPTS }}
|
||||
POLL_DELAY_MS: ${{ env.POLL_DELAY_MS }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
// Poll for Copilot's response — check every 30s for up to 10 minutes
|
||||
const maxAttempts = parseInt(process.env.MAX_POLL_ATTEMPTS, 10);
|
||||
const delayMs = parseInt(process.env.POLL_DELAY_MS, 10);
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
console.log(`Poll attempt ${i + 1}/${maxAttempts}...`);
|
||||
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr,
|
||||
owner,
|
||||
repo,
|
||||
per_page: 50,
|
||||
sort: 'created',
|
||||
direction: 'desc'
|
||||
});
|
||||
|
||||
// Look for Copilot's response (copilot-swe-agent or Copilot)
|
||||
const copilotResponse = comments.data.find(c =>
|
||||
(c.user.login === 'copilot-swe-agent' ||
|
||||
c.user.login === 'Copilot' ||
|
||||
c.user.login.toLowerCase().includes('copilot')) &&
|
||||
c.user.type === 'Bot'
|
||||
);
|
||||
|
||||
if (copilotResponse) {
|
||||
console.log(`Copilot responded at ${copilotResponse.created_at}`);
|
||||
core.setOutput('copilot_responded', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
// Also check PR reviews for Copilot
|
||||
const reviews = await github.rest.pulls.listReviews({
|
||||
pull_number: pr,
|
||||
owner,
|
||||
repo
|
||||
});
|
||||
|
||||
const copilotReview = reviews.data.find(r =>
|
||||
r.user.login.toLowerCase().includes('copilot')
|
||||
);
|
||||
|
||||
if (copilotReview) {
|
||||
console.log(`Copilot review submitted at ${copilotReview.submitted_at}`);
|
||||
core.setOutput('copilot_responded', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
if (i < maxAttempts - 1) {
|
||||
console.log(`Copilot hasn't responded yet, waiting ${delayMs/1000}s...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Copilot did not respond within timeout, triggering Claude anyway');
|
||||
core.setOutput('copilot_responded', 'timeout');
|
||||
|
||||
- name: Check if Claude already triggered
|
||||
id: check_claude
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ 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
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
|
||||
- name: Trigger Claude final review
|
||||
if: steps.check_claude.outputs.already_triggered != 'true'
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const scope = process.env.CLAUDE_UI_SCOPE;
|
||||
const phases = process.env.CLAUDE_FINAL_PHASES_DETAILED;
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAIUI (not a fork), you are able to make modifications to this branch and push directly. ' + scope + ' Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n' + phases
|
||||
});
|
||||
console.log(`Claude triggered on PR #${pr}`);
|
||||
|
||||
- name: Sync pipeline status labels
|
||||
if: steps.check_claude.outputs.already_triggered != 'true'
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
|
||||
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
|
||||
# ================================================================
|
||||
# Claude after Qodo fallback path (same polling approach)
|
||||
# ================================================================
|
||||
claude-after-copilot-qodo:
|
||||
needs: [copilot-after-qodo]
|
||||
if: |
|
||||
always() &&
|
||||
needs.copilot-after-qodo.result == 'success' &&
|
||||
needs.copilot-after-qodo.outputs.triggered == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Wait for Copilot to respond (poll)
|
||||
id: poll
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
|
||||
MAX_POLL_ATTEMPTS: ${{ env.MAX_POLL_ATTEMPTS }}
|
||||
POLL_DELAY_MS: ${{ env.POLL_DELAY_MS }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const maxAttempts = parseInt(process.env.MAX_POLL_ATTEMPTS, 10);
|
||||
const delayMs = parseInt(process.env.POLL_DELAY_MS, 10);
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
console.log(`Poll attempt ${i + 1}/${maxAttempts}...`);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr, owner, repo,
|
||||
per_page: 50, sort: 'created', direction: 'desc'
|
||||
});
|
||||
const copilotResponse = comments.data.find(c =>
|
||||
(c.user.login === 'copilot-swe-agent' ||
|
||||
c.user.login === 'Copilot' ||
|
||||
c.user.login.toLowerCase().includes('copilot')) &&
|
||||
c.user.type === 'Bot'
|
||||
);
|
||||
if (copilotResponse) {
|
||||
console.log(`Copilot responded at ${copilotResponse.created_at}`);
|
||||
core.setOutput('copilot_responded', 'true');
|
||||
return;
|
||||
}
|
||||
const reviews = await github.rest.pulls.listReviews({
|
||||
pull_number: pr, owner, repo
|
||||
});
|
||||
const copilotReview = reviews.data.find(r =>
|
||||
r.user.login.toLowerCase().includes('copilot')
|
||||
);
|
||||
if (copilotReview) {
|
||||
console.log(`Copilot review submitted at ${copilotReview.submitted_at}`);
|
||||
core.setOutput('copilot_responded', 'true');
|
||||
return;
|
||||
}
|
||||
if (i < maxAttempts - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
console.log('Copilot did not respond within timeout, triggering Claude anyway');
|
||||
core.setOutput('copilot_responded', 'timeout');
|
||||
|
||||
- name: Check if Claude already triggered
|
||||
id: check_claude
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ 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
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
|
||||
- name: Trigger Claude final review
|
||||
if: steps.check_claude.outputs.already_triggered != 'true'
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const scope = process.env.CLAUDE_UI_SCOPE;
|
||||
const phases = process.env.CLAUDE_FINAL_PHASES_DETAILED;
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAIUI (not a fork), you are able to make modifications to this branch and push directly. ' + scope + ' Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n' + phases
|
||||
});
|
||||
console.log(`Claude triggered on PR #${pr}`);
|
||||
|
||||
# ================================================================
|
||||
# Claude after Gemini — restores the trigger lost from the deleted
|
||||
# chain-claude-after-copilot.yml workflow.
|
||||
# Fires when Gemini posts a review comment on a PR or issue.
|
||||
# ================================================================
|
||||
claude-after-gemini:
|
||||
if: |
|
||||
github.event_name == 'issue_comment' &&
|
||||
(
|
||||
github.event.comment.user.login == 'gemini-code-assist[bot]' ||
|
||||
contains(github.event.comment.body, 'Review completed by Gemini CLI') ||
|
||||
contains(github.event.comment.body, 'The fix is ready for review. Please test')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check if Claude already triggered
|
||||
id: check_claude
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
|
||||
- name: Post Claude analysis request (Lead Engineer)
|
||||
if: steps.check_claude.outputs.already_triggered != 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const scope = process.env.CLAUDE_UI_SCOPE;
|
||||
const phases = process.env.CLAUDE_FINAL_PHASES_DETAILED;
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the Lead Engineer. If the branch is under MervinPraison/PraisonAIUI (not a fork), you are able to make modifications to this branch and push directly. ' + scope + ' Read ALL analysis and reviews above carefully (Gemini, CodeRabbit, Qodo, Copilot, etc).\n\n' + phases
|
||||
});
|
||||
|
||||
# ================================================================
|
||||
# Recovery: re-post FINAL @claude after push or when chain missed
|
||||
# ================================================================
|
||||
claude-review-recovery:
|
||||
if: |
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.action == 'synchronize'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Re-post FINAL Claude review when stale or missing
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pr-review-chain.js'));
|
||||
const pr = context.payload.pull_request.number;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
const comments = await mergeGate.listAllComments(github, owner, repo, pr);
|
||||
const headPushedAt = (await mergeGate.getHeadCommitDate(github, owner, repo, pr))
|
||||
|| context.payload.pull_request.updated_at;
|
||||
const headPusher = context.payload.sender?.login || null;
|
||||
|
||||
const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(comments);
|
||||
const isStale = mergeGate.isStaleFinalAfterPush(comments, headPushedAt);
|
||||
|
||||
if (mergeGate.shouldSkipFinalRecovery(comments, headPushedAt)) {
|
||||
core.info('Recent @claude within 35min and FINAL not stale — skip recovery');
|
||||
return;
|
||||
}
|
||||
|
||||
if (await mergeGate.hasInProgressClaudeAssistant(github, owner, repo, pr)) {
|
||||
core.info('Claude workflow in progress — skip recovery');
|
||||
return;
|
||||
}
|
||||
|
||||
const finalBody = mergeGate.FINAL_CLAUDE_REVIEW_BODY;
|
||||
const staleOpts = { allowStaleRepost: true, skipCopilot: true, optionalWaitMs: 0 };
|
||||
const missingOpts = { skipCopilot: true, optionalWaitMs: 0 };
|
||||
|
||||
if (hasFinal && isStale) {
|
||||
const gate = mergeGate.shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusher);
|
||||
if (gate.skip) {
|
||||
core.info(`Skip stale-FINAL recovery: ${gate.reason}`);
|
||||
return;
|
||||
}
|
||||
const result = await chain.maybeTriggerClaudeFinal(
|
||||
github, owner, repo, pr, finalBody, core, staleOpts
|
||||
);
|
||||
if (result.posted) core.info(`Posted stale-FINAL recovery @claude on PR #${pr}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasFinal) {
|
||||
core.info('FINAL Claude review current on HEAD — skip recovery');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await chain.maybeTriggerClaudeFinal(
|
||||
github, owner, repo, pr, finalBody, core, missingOpts
|
||||
);
|
||||
if (result.posted) core.info(`Posted missing FINAL Claude review on PR #${pr}`);
|
||||
|
||||
- name: Sync pipeline status labels
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const pr = context.payload.pull_request.number;
|
||||
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
|
||||
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
|
||||
# Scheduled + manual: post missing or stale FINAL @claude on all open PRs
|
||||
post-missing-claude-final:
|
||||
if: |
|
||||
github.event_name == 'schedule' ||
|
||||
(github.event_name == 'workflow_dispatch' && !inputs.pr_number)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Post missing/stale FINAL @claude on open PRs
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pr-review-chain.js'));
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner, repo, state: 'open', per_page: 100,
|
||||
});
|
||||
let posted = 0;
|
||||
for (const pr of prs) {
|
||||
if (pr.draft) continue;
|
||||
const comments = await mergeGate.listAllComments(github, owner, repo, pr.number);
|
||||
const headPushedAt = (await mergeGate.getHeadCommitDate(github, owner, repo, pr.number)) || pr.updated_at;
|
||||
const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(comments);
|
||||
const isStale = mergeGate.isStaleFinalAfterPush(comments, headPushedAt);
|
||||
|
||||
if (hasFinal && !isStale) continue;
|
||||
if (mergeGate.hasRecentClaudeTrigger(comments, 10)) {
|
||||
core.info(`PR #${pr.number}: @claude within 10min — skip`);
|
||||
continue;
|
||||
}
|
||||
if (await mergeGate.hasInProgressClaudeAssistant(github, owner, repo, pr.number)) {
|
||||
core.info(`PR #${pr.number}: Claude in progress — skip`);
|
||||
continue;
|
||||
}
|
||||
if (hasFinal && isStale) {
|
||||
const gate = mergeGate.shouldSkipStaleFinalRecovery(comments, headPushedAt);
|
||||
if (gate.skip) {
|
||||
core.info(`PR #${pr.number}: skip stale — ${gate.reason}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const opts = hasFinal && isStale
|
||||
? { allowStaleRepost: true, skipCopilot: true, optionalWaitMs: 0 }
|
||||
: { skipCopilot: true, optionalWaitMs: 0 };
|
||||
const result = await chain.maybeTriggerClaudeFinal(
|
||||
github, owner, repo, pr.number,
|
||||
mergeGate.FINAL_CLAUDE_REVIEW_BODY, core, opts
|
||||
);
|
||||
if (result.posted) {
|
||||
posted++;
|
||||
core.info(`Posted Claude FINAL on PR #${pr.number}`);
|
||||
}
|
||||
await ps.syncPipelineLabels(github, owner, repo, pr.number, core);
|
||||
if (posted >= 10) break;
|
||||
}
|
||||
core.info(`Missing/stale Claude FINAL sweep complete (${posted} posted)`);
|
||||
|
||||
# ================================================================
|
||||
# BOT PRs: CodeRabbit → Claude directly (skip Copilot when no user PAT)
|
||||
# Copilot ignores github-actions[bot] comments; bot PRs use this path.
|
||||
# ================================================================
|
||||
claude-after-coderabbit-bot-pr:
|
||||
if: |
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
github.event.comment.user.login == 'coderabbitai[bot]' &&
|
||||
contains(github.event.comment.body, 'summarize by coderabbit')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Trigger Claude final review on bot PR
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = context.issue.number;
|
||||
const { data: pull } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr,
|
||||
});
|
||||
const author = pull.user.login;
|
||||
const isBot = author === 'github-actions[bot]' ||
|
||||
author === 'praisonai-triage-agent[bot]' ||
|
||||
pull.user.type === 'Bot';
|
||||
if (!isBot) {
|
||||
console.log('Human PR — Copilot chain handles Claude trigger');
|
||||
return;
|
||||
}
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 100,
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
if (alreadyPosted) {
|
||||
console.log('Claude already triggered on PR #' + pr);
|
||||
return;
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the FINAL architecture reviewer on this bot-authored PR. Push fixes directly to THIS branch (do NOT open a new PR). ' + process.env.CLAUDE_UI_SCOPE + ' Read ALL CodeRabbit/Qodo comments above.\n\n' + process.env.CLAUDE_FINAL_PHASES_COMPACT
|
||||
});
|
||||
console.log('Claude final review triggered on bot PR #' + pr);
|
||||
|
||||
# ================================================================
|
||||
# Manual: trigger Claude final review on any PR
|
||||
# ================================================================
|
||||
claude-manual-dispatch:
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.pr_number
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Trigger Claude final review
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ inputs.pr_number }}
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 100,
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
if (alreadyPosted) {
|
||||
console.log('Claude already triggered on PR #' + pr);
|
||||
return;
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAIUI (not a fork), you are able to make modifications to this branch and push directly. ' + process.env.CLAUDE_UI_SCOPE + ' Read all prior reviewer comments.\n\n' + process.env.CLAUDE_FINAL_PHASES_COMPACT
|
||||
});
|
||||
console.log('Claude manually triggered on PR #' + pr);
|
||||
|
||||
# ================================================================
|
||||
# BOT PRs: kick CodeRabbit + Qodo
|
||||
# ================================================================
|
||||
bot-pr-trigger-reviews:
|
||||
if: |
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.action == 'opened' &&
|
||||
(
|
||||
github.event.pull_request.user.login == 'github-actions[bot]' ||
|
||||
github.event.pull_request.user.login == 'praisonai-triage-agent[bot]' ||
|
||||
github.event.pull_request.user.type == 'Bot'
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Trigger CodeRabbit and Qodo reviews
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/bot-pr-review-chain.js'));
|
||||
const pr = context.payload.pull_request.number;
|
||||
await chain.kickReviewChain(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
|
||||
- name: Sync pipeline status labels
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const pr = context.payload.pull_request.number;
|
||||
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
|
||||
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.6
|
||||
@@ -1,7 +1,5 @@
|
||||
name: Bot PR Recovery
|
||||
|
||||
# Belt-and-braces: kick CodeRabbit/Qodo when pull_request:opened never fired for bot PRs.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/15 * * * *'
|
||||
@@ -18,26 +16,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
recover:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Find and kick stalled bot PRs
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER_INPUT: ${{ github.event.inputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/bot-pr-review-chain.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const manual = (process.env.PR_NUMBER_INPUT || '').trim();
|
||||
const prNumber = manual ? parseInt(manual, 10) : null;
|
||||
if (manual && Number.isNaN(prNumber)) {
|
||||
throw new Error(`Invalid pr_number: ${manual}`);
|
||||
}
|
||||
await chain.recoverStalledBotPrs(github, owner, repo, { prNumber }, core);
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.6
|
||||
pr_number: ${{ github.event.inputs.pr_number || '' }}
|
||||
@@ -1,17 +1,14 @@
|
||||
name: Auto CI Failure → Claude
|
||||
|
||||
# Detects internal PRs with failing CI and posts @claude with extracted failure details.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
workflows: [Core Tests, Optimized Test Suite]
|
||||
types: [completed]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'Optional PR number to process (default: scan blocked PRs)'
|
||||
required: false
|
||||
type: string
|
||||
schedule:
|
||||
@@ -21,10 +18,6 @@ concurrency:
|
||||
group: ci-failure-scan-${{ github.repository }}-${{ github.event.pull_request.number || github.event.workflow_run.id || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# PAT posts as MervinPraison so issue_comment triggers claude.yml (GITHUB_TOKEN cannot chain workflows)
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
issues: write
|
||||
@@ -32,71 +25,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
detect-and-trigger:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Detect CI failures and trigger Claude
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ciFix = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/ci-failure-claude.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const baseRepo = `${owner}/${repo}`;
|
||||
|
||||
async function processPR(prNumber) {
|
||||
return ciFix.maybeTriggerCiFixClaude(github, owner, repo, prNumber, core);
|
||||
}
|
||||
|
||||
let results = [];
|
||||
|
||||
if (context.eventName === 'workflow_run') {
|
||||
const wr = context.payload.workflow_run;
|
||||
const result = await ciFix.processWorkflowRunFailure(
|
||||
github, owner, repo, wr, core
|
||||
);
|
||||
results.push({ event: 'workflow_run', workflow: wr.name, ...result });
|
||||
} else if (context.eventName === 'pull_request') {
|
||||
results.push({
|
||||
pr: context.payload.pull_request.number,
|
||||
...(await processPR(context.payload.pull_request.number)),
|
||||
});
|
||||
} else {
|
||||
let prNumbers = [];
|
||||
if (context.eventName === 'workflow_dispatch' && context.payload.inputs?.pr_number) {
|
||||
prNumbers = [Number(context.payload.inputs.pr_number)];
|
||||
} else {
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
for (const pr of prs) {
|
||||
if (pr.draft) continue;
|
||||
const headRepo = pr.head?.repo?.full_name;
|
||||
if (headRepo && headRepo !== baseRepo) continue;
|
||||
const { data: issue } = await github.rest.issues.get({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
});
|
||||
const labels = issue.labels.map((l) => l.name);
|
||||
if (labels.includes('pipeline/blocked:ci') || labels.includes(ciFix.CI_FIX_LABEL)) {
|
||||
prNumbers.push(pr.number);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const num of prNumbers) {
|
||||
results.push({ pr: num, ...(await processPR(num)) });
|
||||
}
|
||||
}
|
||||
|
||||
core.summary.addRaw('```json\n' + JSON.stringify(results, null, 2) + '\n```');
|
||||
await core.summary.write();
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.6
|
||||
pr_number: ${{ github.event.inputs.pr_number || '' }}
|
||||
@@ -5,6 +5,7 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
python:
|
||||
@@ -65,3 +66,4 @@ jobs:
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./site
|
||||
force_orphan: true
|
||||
@@ -1,46 +1,30 @@
|
||||
name: Claude PR Merge Gate
|
||||
|
||||
# Read-only Claude assesses merge readiness; GH_TOKEN merges (no @claude, no gh pr review).
|
||||
# Does not overlap with merge-conflict-claude.yml or auto-pr-comment @claude chains.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Scan for merge-ready PRs every 10 minutes (assess runs only when candidates exist).
|
||||
- cron: '*/10 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'Optional PR number (scan all open internal PRs if empty)'
|
||||
required: false
|
||||
type: string
|
||||
merge_method:
|
||||
description: 'Merge method'
|
||||
required: false
|
||||
type: choice
|
||||
default: merge
|
||||
options:
|
||||
- merge
|
||||
- squash
|
||||
- rebase
|
||||
options: [merge, squash, rebase]
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Claude Assistant
|
||||
- CI
|
||||
types:
|
||||
- completed
|
||||
- Core Tests
|
||||
types: [completed]
|
||||
repository_dispatch:
|
||||
types:
|
||||
- claude-merge-gate
|
||||
types: [claude-merge-gate]
|
||||
|
||||
concurrency:
|
||||
group: claude-merge-gate-${{ github.repository }}-${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || github.event.workflow_run.id || 'scan' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
MAX_CANDIDATES: 3
|
||||
DISPATCH_PR_NUMBER: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -59,7 +43,9 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected]
|
||||
with:
|
||||
hub_ref: v1.1.6
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
@@ -67,473 +53,43 @@ jobs:
|
||||
app-id: ${{ secrets.CLAUDE_APP_ID }}
|
||||
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- name: Dispatch merge gate when PR pipeline is ready
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const mergeGate = require(path.join(process.env.HUB_SCRIPTS, 'merge-gate.js'));
|
||||
const wr = context.payload.workflow_run;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
if (wr.conclusion !== 'success') {
|
||||
core.info(`Skip dispatch: ${wr.name} conclusion=${wr.conclusion}`);
|
||||
return;
|
||||
}
|
||||
const allowed = ['Claude Assistant', 'CI'];
|
||||
if (!allowed.includes(wr.name)) {
|
||||
core.info(`Skip dispatch: workflow ${wr.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (wr.conclusion !== 'success') return;
|
||||
const allowed = ['Claude Assistant', 'Core Tests'];
|
||||
if (!allowed.includes(wr.name)) return;
|
||||
const prNumber = await mergeGate.resolvePrNumberFromWorkflowRun(github, owner, repo, wr);
|
||||
if (!prNumber) {
|
||||
core.info('Skip dispatch: could not resolve PR from workflow run');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!prNumber) return;
|
||||
const fromClaude = wr.name === 'Claude Assistant';
|
||||
const check = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core, {
|
||||
skipGlobalClaudeRunCheck: fromClaude,
|
||||
skipRecentClaudeCooldown: fromClaude,
|
||||
}
|
||||
);
|
||||
if (!check.ready) {
|
||||
core.info(`PR #${prNumber} not ready: ${check.reasons.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const check = await mergeGate.evaluatePipelineQuiescent(github, owner, repo, prNumber, core, {
|
||||
skipGlobalClaudeRunCheck: fromClaude,
|
||||
skipRecentClaudeCooldown: fromClaude,
|
||||
});
|
||||
if (!check.ready) return;
|
||||
await github.rest.repos.createDispatchEvent({
|
||||
owner,
|
||||
repo,
|
||||
event_type: 'claude-merge-gate',
|
||||
owner, repo, event_type: 'claude-merge-gate',
|
||||
client_payload: { pr_number: prNumber },
|
||||
});
|
||||
core.info(`Dispatched merge gate for PR #${prNumber} after ${wr.name}`);
|
||||
|
||||
scan-candidates:
|
||||
merge-gate:
|
||||
if: github.event_name != 'workflow_run'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
outputs:
|
||||
matrix: ${{ steps.scan.outputs.matrix }}
|
||||
has_candidates: ${{ steps.scan.outputs.has_candidates }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.CLAUDE_APP_ID }}
|
||||
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- name: Scan open PRs for merge gate eligibility
|
||||
id: scan
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const maxCandidates = parseInt(process.env.MAX_CANDIDATES || '5', 10);
|
||||
const inputPr = String(process.env.DISPATCH_PR_NUMBER || '').trim();
|
||||
|
||||
const candidates = [];
|
||||
const skipped = [];
|
||||
|
||||
if (inputPr) {
|
||||
const n = parseInt(inputPr, 10);
|
||||
if (Number.isNaN(n)) {
|
||||
throw new Error(`Invalid pr_number input: ${inputPr}`);
|
||||
}
|
||||
const result = await mergeGate.evaluatePipelineQuiescent(github, owner, repo, n, core);
|
||||
if (result.ready) {
|
||||
candidates.push({ pr_number: n, head_sha: result.headSha });
|
||||
} else {
|
||||
skipped.push({ pr: n, reasons: result.reasons });
|
||||
}
|
||||
} else {
|
||||
const prNumbers = await mergeGate.listPrNumbersForMergeGateScan(
|
||||
github, owner, repo, core
|
||||
);
|
||||
const selected = await mergeGate.selectMergeGateCandidates(
|
||||
github, owner, repo, prNumbers, maxCandidates, core
|
||||
);
|
||||
candidates.push(...selected.candidates);
|
||||
skipped.push(...selected.skipped);
|
||||
}
|
||||
|
||||
core.info('Skipped: ' + JSON.stringify(skipped));
|
||||
core.setOutput('matrix', JSON.stringify({ include: candidates }));
|
||||
core.setOutput('has_candidates', candidates.length > 0 ? 'true' : 'false');
|
||||
|
||||
core.summary.addRaw('## Merge gate scan\n');
|
||||
core.summary.addRaw(`Candidates: ${candidates.length}\n`);
|
||||
core.summary.addRaw('```json\n' + JSON.stringify({ candidates, skipped }, null, 2) + '\n```');
|
||||
await core.summary.write();
|
||||
|
||||
if (inputPr && skipped.length === 1 && skipped[0].pr === parseInt(inputPr, 10)) {
|
||||
const reasons = skipped[0].reasons;
|
||||
const body = [
|
||||
'**Merge gate scan** — not eligible for auto-merge.',
|
||||
'',
|
||||
...reasons.map((r) => `- \`${r}\``),
|
||||
'',
|
||||
'**Actions:** wait for CI and the Claude review chain, or add label `needs-manual-review` and merge manually.',
|
||||
'**Opt out:** label `no-auto-merge`.',
|
||||
].join('\n');
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: skipped[0].pr, body,
|
||||
});
|
||||
} else if (inputPr && candidates.length === 1) {
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: candidates[0].pr_number,
|
||||
body: '**Merge gate scan** — eligible for assessment. Claude merge gate will assess and may auto-merge if `MERGE_GATE_VERDICT: APPROVE`.',
|
||||
});
|
||||
}
|
||||
|
||||
await ps.syncOpenPullRequests(github, owner, repo, { maxPrs: 10 }, core);
|
||||
|
||||
claude-assess:
|
||||
needs: scan-candidates
|
||||
if: needs.scan-candidates.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.scan-candidates.outputs.matrix) }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.CLAUDE_APP_ID }}
|
||||
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Mark merge gate active
|
||||
id: mark_active
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
core.setOutput('started_at', new Date().toISOString());
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: ${{ matrix.pr_number }},
|
||||
labels: ['claude-merge-gate-active'],
|
||||
});
|
||||
|
||||
- name: Stash merge gate helpers
|
||||
run: |
|
||||
# Keep scripts together so require('./gate-config.js') works from /tmp
|
||||
mkdir -p /tmp/merge-gate-scripts /tmp/gh-actions
|
||||
cp .github/scripts/merge-gate.js .github/scripts/gate-config.js /tmp/merge-gate-scripts/
|
||||
cp -R .github/actions/claude-code-action /tmp/gh-actions/
|
||||
|
||||
- name: Fetch PR branch
|
||||
env:
|
||||
PR_NUMBER: ${{ matrix.pr_number }}
|
||||
run: |
|
||||
git fetch origin "pull/${PR_NUMBER}/head:pr-${PR_NUMBER}"
|
||||
git checkout "pr-${PR_NUMBER}"
|
||||
mkdir -p .github/actions
|
||||
rm -rf .github/actions/claude-code-action
|
||||
cp -R /tmp/gh-actions/claude-code-action .github/actions/
|
||||
|
||||
- name: Claude merge gate assessment (read-only)
|
||||
id: assess
|
||||
uses: ./.github/actions/claude-code-action
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
CLAUDE_CODE_SUBAGENT_MODEL: inherit
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: claude-sonnet-4-6
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ steps.app-token.outputs.token }}
|
||||
model: claude-opus-4-8
|
||||
trigger_phrase: '@claude-merge-gate-internal'
|
||||
direct_prompt: |
|
||||
You are the MERGE GATE reviewer for PraisonAIUI PR #${{ matrix.pr_number }}.
|
||||
READ-ONLY: do not modify code, commit, push, rebase, or create branches.
|
||||
|
||||
CRITICAL OUTPUT RULE:
|
||||
Post exactly one PR comment via GitHub MCP containing either:
|
||||
MERGE_GATE_VERDICT: APPROVE
|
||||
or
|
||||
MERGE_GATE_VERDICT: BLOCK
|
||||
followed by a short rationale on the next lines.
|
||||
|
||||
NEVER include @claude in any comment. NEVER mention merge conflict resolution.
|
||||
|
||||
Review checklist:
|
||||
1. Read AGENTS.md and the PR diff on the current branch.
|
||||
2. Read all PR comments and reviews (FINAL architecture reviewer / Lead Engineer must have run).
|
||||
3. Confirm product value gate: change strengthens PraisonAIUI UX (no scope creep). The aim is a LIGHTWEIGHT AND POWERFUL package — BLOCK changes that add params/modules/exports duplicating existing UI capabilities (YAML config, plugins, feature modules, existing server hooks), or add knobs/fields with no live consumer, i.e. scope creep for the sake of it.
|
||||
4. Layering routing: BLOCK if core agent logic was added here instead of praisonaiagents; BLOCK if agent-callable tools were added here instead of PraisonAI-Tools.
|
||||
5. Do not bloat server.py or create_app() with additional params — only if absolutely required; prefer existing config/plugin patterns.
|
||||
6. If server.py or create_app() adds >100 lines or new top-level params without a clear live consumer, respond MERGE_GATE_VERDICT: BLOCK (manual review required).
|
||||
7. BLOCK if labels security/breaking-change/needs-manual-review/release, sensitive paths (.github/workflows, auth, pyproject.toml), secrets in diff, PR >800 lines or >30 files, or product code changes without tests. Label `merge-gate-ci-only` exempts CI-only changes under `.github/workflows/`, `.github/actions/`, or merge-gate scripts.
|
||||
8. Confirm no CHANGES_REQUESTED reviews and CI green on HEAD ${{ matrix.head_sha }}.
|
||||
9. If ready to merge: MERGE_GATE_VERDICT: APPROVE. Otherwise: MERGE_GATE_VERDICT: BLOCK with reasons.
|
||||
|
||||
Do not approve or merge via GitHub UI/API — only post the verdict comment.
|
||||
disallowed_tools: |
|
||||
Bash
|
||||
Edit
|
||||
Write
|
||||
Replace
|
||||
allowed_tools: |
|
||||
View
|
||||
GlobTool
|
||||
GrepTool
|
||||
Read
|
||||
timeout_minutes: 15
|
||||
|
||||
- name: Post fallback BLOCK if Opus did not comment
|
||||
id: fallback_verdict
|
||||
if: steps.assess.outcome != 'success'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const mergeGate = require('/tmp/merge-gate-scripts/merge-gate.js');
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prNumber = ${{ matrix.pr_number }};
|
||||
|
||||
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
|
||||
const minCreatedAt = new Date(Date.now() - 25 * 60 * 1000).toISOString();
|
||||
const existing = mergeGate.findMergeGateVerdict(
|
||||
ctx.comments, minCreatedAt, ctx.headPushedAt, { excludeAutomatedFallback: true }
|
||||
);
|
||||
if (existing) {
|
||||
core.info(`Opus verdict already posted: ${existing}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const check = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core, { forMergeStep: true }
|
||||
);
|
||||
const lines = [
|
||||
'MERGE_GATE_VERDICT: BLOCK',
|
||||
'',
|
||||
'Automated fallback — Opus merge gate assessment did not complete.',
|
||||
];
|
||||
if (check.ready) {
|
||||
lines.push('Deterministic gates passed, but Opus MERGE_GATE_VERDICT is required before merge.');
|
||||
} else {
|
||||
lines.push('Blockers:', ...check.reasons.map((r) => `- ${r}`));
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: prNumber,
|
||||
body: lines.join('\n'),
|
||||
});
|
||||
core.info('Posted fallback BLOCK — Opus verdict required');
|
||||
|
||||
- name: Require Opus MERGE_GATE_VERDICT
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const mergeGate = require('/tmp/merge-gate-scripts/merge-gate.js');
|
||||
const comments = await mergeGate.listAllComments(
|
||||
github, context.repo.owner, context.repo.repo, ${{ matrix.pr_number }}
|
||||
);
|
||||
const ctx = await mergeGate.loadPrContext(
|
||||
github, context.repo.owner, context.repo.repo, ${{ matrix.pr_number }}
|
||||
);
|
||||
const minCreatedAt = new Date(Date.now() - 25 * 60 * 1000).toISOString();
|
||||
const verdict = mergeGate.findMergeGateVerdict(
|
||||
comments, minCreatedAt, ctx.headPushedAt, { excludeAutomatedFallback: true }
|
||||
);
|
||||
if (!verdict) {
|
||||
core.setFailed(
|
||||
'Opus merge gate assessment must post MERGE_GATE_VERDICT on this PR (automated fallback APPROVE is not accepted).'
|
||||
);
|
||||
return;
|
||||
}
|
||||
core.info(`Opus merge gate verdict: ${verdict}`);
|
||||
core.setOutput('verdict', verdict);
|
||||
|
||||
- name: Remove merge gate active label
|
||||
if: always()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const prNumber = ${{ matrix.pr_number }};
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
name: 'claude-merge-gate-active',
|
||||
});
|
||||
core.info(`Removed claude-merge-gate-active from PR #${prNumber}`);
|
||||
} catch (e) {
|
||||
core.info(`Label claude-merge-gate-active already removed or missing on PR #${prNumber}`);
|
||||
}
|
||||
|
||||
- name: Fail job if assessment failed
|
||||
if: steps.assess.outcome == 'failure'
|
||||
run: exit 1
|
||||
|
||||
merge-only:
|
||||
needs: [scan-candidates, claude-assess]
|
||||
if: |
|
||||
always() &&
|
||||
needs.scan-candidates.outputs.has_candidates == 'true' &&
|
||||
needs.claude-assess.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.scan-candidates.outputs.matrix) }}
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.CLAUDE_APP_ID }}
|
||||
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- name: Re-check gates and merge
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
MERGE_METHOD: ${{ github.event.inputs.merge_method || 'merge' }}
|
||||
SCAN_HEAD_SHA: ${{ matrix.head_sha }}
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prNumber = ${{ matrix.pr_number }};
|
||||
const scanHeadSha = process.env.SCAN_HEAD_SHA;
|
||||
|
||||
const removeLabels = async () => {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner, repo, issue_number: prNumber, name: 'claude-merge-gate-active',
|
||||
});
|
||||
} catch (e) {
|
||||
core.info('Label claude-merge-gate-active already removed or missing');
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
|
||||
const comments = ctx.comments;
|
||||
const verdictMin = new Date(Date.now() - 25 * 60 * 1000).toISOString();
|
||||
const verdict = mergeGate.findMergeGateVerdict(
|
||||
comments, verdictMin, ctx.headPushedAt, { excludeAutomatedFallback: true }
|
||||
);
|
||||
if (verdict !== 'APPROVE') {
|
||||
core.info(`Skip merge: verdict=${verdict || 'NONE'}`);
|
||||
await removeLabels();
|
||||
return;
|
||||
}
|
||||
|
||||
const check = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core, { forMergeStep: true }
|
||||
);
|
||||
if (!check.ready) {
|
||||
core.info(`Skip merge: ${check.reasons.join(', ')}`);
|
||||
await removeLabels();
|
||||
return;
|
||||
}
|
||||
|
||||
if (check.headSha !== scanHeadSha) {
|
||||
core.info(`Skip merge: HEAD moved ${scanHeadSha.slice(0, 7)} → ${check.headSha.slice(0, 7)}`);
|
||||
await removeLabels();
|
||||
return;
|
||||
}
|
||||
|
||||
const method = process.env.MERGE_METHOD || 'merge';
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
let merged = false;
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
await github.rest.pulls.merge({
|
||||
owner, repo, pull_number: prNumber, merge_method: method,
|
||||
});
|
||||
merged = true;
|
||||
break;
|
||||
} catch (e) {
|
||||
const msg = (e.message || '').toLowerCase();
|
||||
if (attempt < 3 && msg.includes('base branch was modified')) {
|
||||
core.info(`Merge attempt ${attempt} failed (base moved), retrying in 8s...`);
|
||||
await sleep(8000);
|
||||
const recheck = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core, { forMergeStep: true }
|
||||
);
|
||||
if (!recheck.ready) {
|
||||
core.info(`Skip merge retry: ${recheck.reasons.join(', ')}`);
|
||||
await removeLabels();
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
if (!merged) {
|
||||
core.info('Skip merge: exhausted retries');
|
||||
await removeLabels();
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number: prNumber, labels: ['auto-merged-by-gate'],
|
||||
});
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: prNumber,
|
||||
body: [
|
||||
'Merged by **Claude PR merge gate** (`claude-merge-gate.yml`).',
|
||||
`Verdict: MERGE_GATE_VERDICT: APPROVE`,
|
||||
`SHA: \`${check.headSha.slice(0, 7)}\``,
|
||||
`Method: ${method}`,
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
core.info(`Merged PR #${prNumber}`);
|
||||
} finally {
|
||||
await removeLabels();
|
||||
}
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.6
|
||||
pr_number: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }}
|
||||
merge_method: ${{ github.event.inputs.merge_method || 'merge' }}
|
||||
@@ -1,557 +1,24 @@
|
||||
name: Claude Assistant
|
||||
|
||||
# Triggers:
|
||||
# - issues:opened → lightweight triage (labels + auto-claude for owner)
|
||||
# - issues:labeled → full implementation when `claude` label applied
|
||||
# - issues:assigned → full implementation when issue assigned to Claude
|
||||
# - issue_comment → full implementation when @claude mentioned (issues + PRs)
|
||||
# - pull_request_review → full implementation when @claude mentioned in review
|
||||
# - pull_request_review_comment → full implementation when @claude mentioned inline
|
||||
#
|
||||
# Escalate an issue with the `claude` label OR an @claude comment — not both.
|
||||
# If both fire within 3 minutes, the comment trigger is skipped (dedupe job).
|
||||
#
|
||||
# Focus: CLAUDE only. Copilot and CodeRabbit are GitHub Apps that react to PRs
|
||||
# automatically — no workflow file needed. auto-pr-comment.yml orchestrates the
|
||||
# CodeRabbit → Copilot → Claude review chain for PRs.
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, labeled, assigned]
|
||||
types: [assigned, labeled, opened]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
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 external users)
|
||||
# Posts welcome comment + triage labels. Auto-adds `claude` for owner issues.
|
||||
# ================================================================
|
||||
issue-triage:
|
||||
if: |
|
||||
github.event_name == 'issues' &&
|
||||
github.event.action == 'opened' &&
|
||||
!contains(toJSON(github.event.issue.labels), 'claude')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.CLAUDE_APP_ID }}
|
||||
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- name: Resolve GitHub token
|
||||
id: github-token
|
||||
run: |
|
||||
if [ -n "${{ steps.app-token.outputs.token }}" ]; then
|
||||
echo "token=${{ steps.app-token.outputs.token }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "token=${{ secrets.GITHUB_TOKEN }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Triage and acknowledge issue
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.github-token.outputs.token }}
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
const title = (issue.title || '').toLowerCase();
|
||||
const body = (issue.body || '').toLowerCase();
|
||||
const content = title + ' ' + body;
|
||||
const isOwner = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(
|
||||
issue.author_association
|
||||
);
|
||||
|
||||
const labels = [];
|
||||
|
||||
if (content.includes('feature request') || content.includes('[feature')) {
|
||||
labels.push('enhancement');
|
||||
} else if (content.includes('bug') || content.includes('error') || content.includes('crash') || content.includes('traceback')) {
|
||||
labels.push('bug');
|
||||
} else if (content.includes('question') || content.includes('how do i') || content.includes('how to')) {
|
||||
labels.push('question');
|
||||
}
|
||||
|
||||
if (content.includes('security') || content.includes('cve') || content.includes('vulnerability')) {
|
||||
labels.push('security');
|
||||
}
|
||||
if (content.includes('performance') || content.includes('slow') || content.includes('memory leak')) {
|
||||
labels.push('performance');
|
||||
}
|
||||
if (content.includes('documentation') || content.includes('docs')) {
|
||||
labels.push('documentation');
|
||||
}
|
||||
if (content.includes('typescript') || content.includes('javascript') || content.includes('npm')) {
|
||||
labels.push('javascript');
|
||||
}
|
||||
if (content.includes('frontend') || content.includes('react') || content.includes('vite')) {
|
||||
labels.push('frontend');
|
||||
}
|
||||
if (content.includes('example') || content.includes('yaml')) {
|
||||
labels.push('examples');
|
||||
}
|
||||
|
||||
if (labels.length > 0) {
|
||||
await github.rest.issues.addLabels({
|
||||
issue_number: issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels
|
||||
});
|
||||
}
|
||||
|
||||
if (!isOwner) {
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: [
|
||||
`👋 Thanks for opening this issue, @${issue.user.login}!`,
|
||||
'',
|
||||
'A maintainer will review this shortly. In the meantime:',
|
||||
'- Make sure you\'ve included steps to reproduce (for bugs)',
|
||||
'- Check [existing issues](https://github.com/MervinPraison/PraisonAIUI/issues) for duplicates',
|
||||
'- Review [AGENTS.md](https://github.com/MervinPraison/PraisonAIUI/blob/main/AGENTS.md) for project conventions',
|
||||
'',
|
||||
'_A maintainer can trigger deeper analysis by commenting `@claude` on this issue._'
|
||||
].join('\n')
|
||||
});
|
||||
}
|
||||
|
||||
if (isOwner) {
|
||||
await github.rest.issues.addLabels({
|
||||
issue_number: issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: ['claude']
|
||||
});
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# Job 2 — Full Claude Code implementation
|
||||
# Triggers on: labeled `claude`, @claude comments, assignments
|
||||
#
|
||||
# Dedup: label + @claude comment on the same issue fires two events.
|
||||
# Skip the comment trigger when `claude` was labelled within 3 minutes.
|
||||
# ================================================================
|
||||
claude-dedupe:
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(
|
||||
github.event.action != 'opened' &&
|
||||
(github.event.action != 'labeled' || github.event.label.name == 'claude') &&
|
||||
(github.event_name != 'issue_comment' || contains(github.event.comment.body, '@claude')) &&
|
||||
(
|
||||
!contains(github.actor, '[bot]') ||
|
||||
github.actor == 'github-actions[bot]' ||
|
||||
github.actor == 'praisonai-triage-agent[bot]'
|
||||
) &&
|
||||
github.actor != 'dependabot[bot]' &&
|
||||
github.actor != 'cursor[bot]' &&
|
||||
github.actor != 'renovate[bot]'
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
proceed: ${{ steps.check.outputs.proceed }}
|
||||
permissions:
|
||||
issues: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Skip duplicate comment after fresh claude label
|
||||
id: check
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
if (context.eventName === 'workflow_dispatch') {
|
||||
core.setOutput('proceed', 'true');
|
||||
return;
|
||||
}
|
||||
if (context.eventName !== 'issue_comment' || context.payload.issue?.pull_request) {
|
||||
core.setOutput('proceed', 'true');
|
||||
return;
|
||||
}
|
||||
const issue = context.payload.issue;
|
||||
const hasClaude = (issue.labels || []).some(l => l.name === 'claude');
|
||||
if (!hasClaude) {
|
||||
core.setOutput('proceed', 'true');
|
||||
return;
|
||||
}
|
||||
const events = await github.rest.issues.listEvents({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
per_page: 30,
|
||||
});
|
||||
const windowMs = 3 * 60 * 1000;
|
||||
const recentLabel = events.data.find(e =>
|
||||
e.event === 'labeled' &&
|
||||
e.label?.name === 'claude' &&
|
||||
(Date.now() - new Date(e.created_at).getTime()) < windowMs
|
||||
);
|
||||
if (recentLabel) {
|
||||
core.info('Skipping: claude label was added within 3 minutes — label trigger already handles this issue.');
|
||||
core.setOutput('proceed', 'false');
|
||||
return;
|
||||
}
|
||||
core.setOutput('proceed', 'true');
|
||||
|
||||
claude-response:
|
||||
needs: claude-dedupe
|
||||
env:
|
||||
ISSUE_NUMBER_RESOLVED: ${{ github.event.issue.number || github.event.pull_request.number || inputs.issue_number }}
|
||||
if: needs.claude-dedupe.outputs.proceed == 'true'
|
||||
concurrency:
|
||||
group: claude-${{ github.event.issue.number || github.event.pull_request.number || inputs.issue_number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
runs-on: ubuntu-latest
|
||||
claude:
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.CLAUDE_APP_ID }}
|
||||
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- name: Resolve GitHub token
|
||||
id: github-token
|
||||
run: |
|
||||
if [ -n "${{ steps.app-token.outputs.token }}" ]; then
|
||||
echo "token=${{ steps.app-token.outputs.token }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "token=${{ secrets.GITHUB_TOKEN }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Check for Fork PR
|
||||
id: check_fork
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.github-token.outputs.token }}
|
||||
script: |
|
||||
const isPR = context.eventName === 'pull_request' ||
|
||||
context.eventName === 'pull_request_review' ||
|
||||
context.eventName === 'pull_request_review_comment' ||
|
||||
(context.payload.issue && context.payload.issue.pull_request);
|
||||
if (isPR) {
|
||||
core.setOutput('is_pr_review', 'true');
|
||||
const prNumber = context.payload.pull_request
|
||||
? context.payload.pull_request.number
|
||||
: context.payload.issue.number;
|
||||
core.setOutput('pr_number', String(prNumber));
|
||||
const pr = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
core.setOutput('pr_branch', pr.data.head.ref);
|
||||
const headRepo = pr.data.head.repo?.full_name || context.repo.full_name;
|
||||
core.setOutput('head_repo', headRepo);
|
||||
|
||||
let mergeState = '';
|
||||
try {
|
||||
const gql = await github.graphql(
|
||||
`query($owner: String!, $repo: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) { mergeStateStatus }
|
||||
}
|
||||
}`,
|
||||
{ owner: context.repo.owner, repo: context.repo.repo, number: prNumber }
|
||||
);
|
||||
mergeState = (gql.repository.pullRequest.mergeStateStatus || '').toUpperCase();
|
||||
} catch (e) {
|
||||
core.warning(`GraphQL mergeStateStatus failed: ${e.message}`);
|
||||
}
|
||||
if (!mergeState) {
|
||||
mergeState = (pr.data.mergeable_state || pr.data.merge_state_status || '').toUpperCase();
|
||||
}
|
||||
core.setOutput('merge_state', mergeState);
|
||||
|
||||
if (headRepo !== `${context.repo.owner}/${context.repo.repo}`) {
|
||||
core.setOutput('is_fork', 'true');
|
||||
core.setOutput('clone_url', pr.data.head.repo.clone_url);
|
||||
core.setOutput('branch', pr.data.head.ref);
|
||||
return;
|
||||
}
|
||||
core.setOutput('is_fork', 'false');
|
||||
return;
|
||||
}
|
||||
core.setOutput('is_pr_review', 'false');
|
||||
core.setOutput('is_fork', 'false');
|
||||
core.setOutput('merge_state', '');
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
token: ${{ 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: 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: ${{ steps.github-token.outputs.token }}
|
||||
with:
|
||||
allowed_bots: 'praisonai-triage-agent[bot]'
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ steps.github-token.outputs.token }}
|
||||
model: claude-opus-4-8
|
||||
trigger_phrase: "@claude"
|
||||
timeout_minutes: "30"
|
||||
direct_prompt: |
|
||||
${{ steps.check_fork.outputs.is_fork == 'true' && 'CRITICAL: FORK PR — comment only, do not push.\n\n' || 'PR on MervinPraison/PraisonAIUI — SAME REPO, NOT A FORK. You HAVE push permission to this branch. NEVER say this is a fork.\n\n' }}
|
||||
${{ steps.check_fork.outputs.is_fork != 'true' && steps.check_fork.outputs.merge_state == 'DIRTY' && format('PRIORITY — RESOLVE MERGE CONFLICTS (branch: {0}, PR #{1}):\n- DO NOT create a new branch or PR\n- git fetch origin main && git rebase origin/main\n- Resolve conflicts: keep this PR intent, merge in main newer logic\n- git add -A && git rebase --continue (repeat until done)\n- git push --force-with-lease origin {0}\n- Run targeted pytest/ruff on changed paths\n- Comment files resolved and what you skipped\n- Only after conflicts are resolved, proceed with review phases below if still needed\n\n', steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.pr_number) || '' }}
|
||||
${{ steps.check_fork.outputs.merge_state != 'DIRTY' && format('CONTEXT: PR #{0} on branch {1}. merge_state={2}.\n\n', steps.check_fork.outputs.pr_number, steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.merge_state) || '' }}
|
||||
You are the Lead Engineer doing FINAL review on this open PR in MervinPraison/PraisonAIUI.
|
||||
Read ALL reviewer comments (CodeRabbit, Gemini, Qodo, Copilot, human). Follow AGENTS.md.
|
||||
SCOPE: src/praisonaiui, src/frontend, tests, docs only.
|
||||
|
||||
**Phase 1 — Review** architecture, security, tests, backward compat, import-time budget.
|
||||
Product value gate: change must strengthen PraisonAIUI UX (no scope creep). BLOCK params/modules/exports
|
||||
duplicating existing UI capabilities or knobs with no live consumer. Core agent logic belongs in
|
||||
praisonaiagents; agent-callable tools in PraisonAI-Tools — not here.
|
||||
**Phase 2 — FIX (mandatory)** implement every VALID issue on THIS branch. Push commits before Phase 3.
|
||||
Do NOT ask the author to fix. Do NOT open a new PR. Do NOT claim fork restrictions unless is_fork is true.
|
||||
**Phase 3 — Verdict** approve only after fixes are pushed and tests pass; else request changes with specifics.
|
||||
|
||||
git config --global user.name "MervinPraison"
|
||||
git config --global user.email "[email protected]"
|
||||
gh auth setup-git
|
||||
git status
|
||||
git branch --show-current
|
||||
allowed_tools: |
|
||||
Bash(git:*)
|
||||
Bash(python:*)
|
||||
Bash(pip:*)
|
||||
Bash(pytest:*)
|
||||
Bash(ruff:*)
|
||||
Bash(gh:*)
|
||||
Bash(npm:*)
|
||||
View
|
||||
GlobTool
|
||||
GrepTool
|
||||
BatchTool
|
||||
Edit
|
||||
Replace
|
||||
|
||||
- name: Run Claude Code Action
|
||||
if: steps.check_fork.outputs.is_pr_review != 'true'
|
||||
uses: anthropics/claude-code-action@beta
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.github-token.outputs.token }}
|
||||
with:
|
||||
allowed_bots: 'praisonai-triage-agent[bot]'
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ steps.github-token.outputs.token }}
|
||||
model: claude-opus-4-8
|
||||
mode: ${{ github.event_name == 'workflow_dispatch' && 'agent' || 'tag' }}
|
||||
trigger_phrase: "@claude"
|
||||
label_trigger: "claude"
|
||||
timeout_minutes: "30"
|
||||
direct_prompt: |
|
||||
${{ steps.check_fork.outputs.is_fork == 'true' && 'CRITICAL: THIS IS A PULL REQUEST FROM A FORK. YOU DO NOT HAVE PUSH PERMISSIONS TO THIS REPOSITORY. ONLY READ FILES, VALIDATE CODE, AND PROVIDE YOUR COMMENTS/FEEDBACK DIRECTLY IN THIS PR VIA COMMENTS. DO NOT ATTEMPT TO PUSH OR CREATE PULL REQUESTS.\n\n' || 'NOTE: The branch is under MervinPraison/PraisonAIUI (not a fork). You are able to make modifications and push directly to this branch.\n\n' }}
|
||||
You are implementing changes in **MervinPraison/PraisonAIUI** (YAML-driven server-driven UI with agentic chat).
|
||||
Follow `AGENTS.md` at the repository root. If a conflict arises between this prompt and `AGENTS.md`, `AGENTS.md` wins.
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
STEP 0 — GIT & GITHUB CLI
|
||||
═══════════════════════════════════════════════════════════════
|
||||
git config --global user.name "MervinPraison"
|
||||
git config --global user.email "[email protected]"
|
||||
gh auth setup-git
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
STEP 1 — READ GUIDELINES
|
||||
═══════════════════════════════════════════════════════════════
|
||||
Read `AGENTS.md` and any `docs/principles.md`. Key invariants:
|
||||
• AIM: This package must stay LIGHTWEIGHT AND POWERFUL. Do a CRITICAL REVIEW at each stage
|
||||
(issue triage, fix planning, PR review/implementation) and reject scope creep for the sake
|
||||
of adding features. Prefer existing YAML config, plugins, feature modules, and server hooks
|
||||
over new server.py/create_app() params or exports. Even when fixing a raised issue, keep the
|
||||
fix minimal — do NOT expand surface area or add knobs with no live consumer.
|
||||
• Cold import of `praisonaiui` must stay under 200 ms; module count must not grow.
|
||||
• Heavy / optional deps (`mcp`, `langchain`, `llama_index`, `semantic_kernel`,
|
||||
`slack_sdk`, `discord.py`, `botbuilder.core`, `openai`, `anthropic`, `mistralai`,
|
||||
`google.generativeai`) must be **lazy-imported** — never eager.
|
||||
• Public API is verb-first. New classes in `src/praisonaiui/message.py` ending
|
||||
in `Message` with a verb prefix (Ask*, Do*, Request*, …) are forbidden —
|
||||
use a module-level `async def` verb helper instead (see `prompt`, `error`).
|
||||
• Deprecations must emit a runtime `warnings.warn(..., DeprecationWarning, stacklevel=2)`
|
||||
on first access, not only in the docstring.
|
||||
• Deterministic serialisation in anything reaching the wire (sorted keys, stable field
|
||||
order). See existing `to_dict` patterns.
|
||||
• Multi-agent + async safe: no global mutable state without a lock.
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
STEP 2 — ARCHITECTURE VALIDATION & ROUTING (MANDATORY before any code)
|
||||
═══════════════════════════════════════════════════════════════
|
||||
Answer these questions inline in your response, then proceed only if you pass:
|
||||
|
||||
ROUTING:
|
||||
1. **Core aiui** (`src/praisonaiui/`) — protocols, message types, server, features.
|
||||
2. **Features** (`src/praisonaiui/features/`) — opt-in capabilities (MCP, platforms, lifecycle, audio, auth, sharing, etc.).
|
||||
3. **UI builders** (`src/praisonaiui/ui.py`) — declarative component helpers.
|
||||
4. **Frontend** (`src/frontend/`) — React components / stores / SSE handlers.
|
||||
5. **Tests** (`tests/unit/`, `tests/integration/`) — new features require both smoke and real-agentic tests.
|
||||
6. **Docs** (`docs/`) — canonical name per feature, migration table for renames.
|
||||
|
||||
EXTERNAL REPOS (do NOT edit from within this repo — route the work to the right place and stop):
|
||||
• Core SDK: `MervinPraison/PraisonAI` (`praisonaiagents`, `praisonai`) — agent runtime.
|
||||
• Tools: `MervinPraison/PraisonAI-Tools` — tool implementations.
|
||||
• Docs site: `MervinPraison/PraisonAIDocs` — product docs.
|
||||
If the requested change belongs in an external repo, comment on the issue explaining
|
||||
where it should live and DO NOT open a PR in this repo.
|
||||
|
||||
CONCEPTUAL CHECKS (if ANY fails — add a comment, do NOT open a PR):
|
||||
• Does it duplicate an existing public symbol in `src/praisonaiui/__init__.py`?
|
||||
• Does it duplicate existing UI capability via YAML config, plugins, or feature modules
|
||||
instead of adding new server params? → Reject as scope creep; prefer existing patterns.
|
||||
• Does it add core agent runtime logic here instead of praisonaiagents, or agent-callable
|
||||
tools here instead of PraisonAI-Tools? → Route externally; do not implement here.
|
||||
• Does it bloat server.py or create_app() with new params without a clear live consumer?
|
||||
→ Prefer existing config/plugin hooks; only add params if absolutely required.
|
||||
• Does it add new class-first message types (`class Ask*Message`, `class *Message`
|
||||
with a verb in the name)? → Use a verb-first helper instead.
|
||||
• Does it add a new hard dependency to `pyproject.toml` `[project] dependencies`?
|
||||
→ Move to `[project.optional-dependencies]` and wrap imports lazily.
|
||||
• Does it break backward compat (rename, signature change, removal)? → Add
|
||||
a deprecation alias that emits `DeprecationWarning`; never hard-remove before the
|
||||
documented removal version (e.g. `0.5.0`).
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
STEP 3 — IMPLEMENT
|
||||
═══════════════════════════════════════════════════════════════
|
||||
Single branch for the whole flow (reuse the same name in STEP 5):
|
||||
BRANCH="claude/issue-${{ env.ISSUE_NUMBER_RESOLVED }}-$(date +%Y%m%d-%H%M%S)"
|
||||
git checkout -b "$BRANCH"
|
||||
|
||||
Make minimal, focused changes:
|
||||
• Stay within the files listed in the issue's "Scope" / "Files to change" section.
|
||||
• Prefer extending existing modules over adding new ones.
|
||||
• No emojis, no comments, no markdown except where the user asked for it.
|
||||
• Python ≥ 3.10 style (`list[...]` not `List[...]`, `X | None` not `Optional[X]`).
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
STEP 4 — TEST / VERIFY
|
||||
═══════════════════════════════════════════════════════════════
|
||||
Run these targeted checks (NOT the full suite):
|
||||
|
||||
# 1. Lint on only the files you changed:
|
||||
CHANGED=$(git diff --name-only origin/main...HEAD | grep -E '\.py$' | tr '\n' ' ')
|
||||
[ -n "$CHANGED" ] && ruff check $CHANGED
|
||||
|
||||
# 2. Unit tests for the module(s) you touched:
|
||||
pytest tests/unit/<your_new_test_file>.py -v --tb=short
|
||||
|
||||
# 3. Import-time invariant (MUST stay under 200 ms cold):
|
||||
python -c "import time, sys; t=time.time(); import praisonaiui; dt=(time.time()-t)*1000; heavy=[m for m in sys.modules if any(h in m for h in ['langchain','llama_index','mcp','slack','discord','botbuilder','openai.','anthropic.','mistralai','google.generativeai'])]; print(f'{dt:.1f}ms modules={len(sys.modules)} leaked={heavy}'); assert heavy == [], f'Heavy deps leaked: {heavy}'"
|
||||
|
||||
If anything fails, fix the root cause (never skip, xfail, or delete the test).
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
STEP 5 — COMMIT, PUSH, OPEN PR (MANDATORY if you produced commits)
|
||||
═══════════════════════════════════════════════════════════════
|
||||
git add -A
|
||||
git commit -m "feat: <short summary> (fixes #${{ env.ISSUE_NUMBER_RESOLVED }})"
|
||||
git push -u origin "$BRANCH"
|
||||
REPO="${{ github.repository }}"
|
||||
COUNT=$(gh pr list --repo "$REPO" --head "$BRANCH" --json number --jq 'length')
|
||||
if [ "$COUNT" -gt 0 ]; then
|
||||
gh pr list --repo "$REPO" --head "$BRANCH" --json url --jq '.[0].url'
|
||||
else
|
||||
gh pr create --repo "$REPO" --base main --head "$BRANCH" \
|
||||
--title "feat: <short title>" \
|
||||
--body "Fixes #${{ env.ISSUE_NUMBER_RESOLVED }}
|
||||
|
||||
## Summary
|
||||
|
||||
<what changed in one paragraph>
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
<copy from the issue; tick boxes only with commit SHA + file path evidence>
|
||||
|
||||
## Test evidence
|
||||
|
||||
\`\`\`
|
||||
<paste pytest output>
|
||||
\`\`\`
|
||||
"
|
||||
fi
|
||||
|
||||
CRITICAL: Do NOT stop after `git push`. You MUST complete `gh pr create` (or print the
|
||||
existing PR URL from the step above) and post that URL on the issue. Never ask a
|
||||
human to open the PR manually.
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
STEP 6 — RESPOND TO REVIEW FEEDBACK (if this run was triggered by a review comment)
|
||||
═══════════════════════════════════════════════════════════════
|
||||
If you were invoked by a PR review comment, you are the Lead Engineer for this PR:
|
||||
• Read ALL reviewer comments in chronological order (CodeRabbit → Copilot → human).
|
||||
• Fix every VALID issue by pushing commits to THIS branch (do NOT open a new PR).
|
||||
• Reply with a summary of which comments you addressed, which you disagreed with (and why).
|
||||
• Never ship a fix that removes/weakens a test.
|
||||
|
||||
allowed_tools: |
|
||||
Bash(git:*)
|
||||
Bash(python:*)
|
||||
Bash(pip:*)
|
||||
Bash(pytest:*)
|
||||
Bash(ruff:*)
|
||||
Bash(gh:*)
|
||||
Bash(npm:*)
|
||||
Bash(npx:*)
|
||||
Bash(python -m pytest:*)
|
||||
Bash(python -m pip:*)
|
||||
Bash(python -m ruff:*)
|
||||
View
|
||||
GlobTool
|
||||
GrepTool
|
||||
BatchTool
|
||||
Edit
|
||||
Replace
|
||||
mcp__github__get_issue
|
||||
mcp__github__get_issue_comments
|
||||
mcp__github__update_issue
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.6
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Deploy Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: deploy-docs
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install AIUI
|
||||
run: pip install -e .
|
||||
|
||||
- name: Build docs with AIUI
|
||||
run: |
|
||||
aiui build --output site --minify
|
||||
cp CNAME site/CNAME
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./site
|
||||
force_orphan: true
|
||||
@@ -1,7 +1,5 @@
|
||||
name: Auto Merge Conflict → Claude
|
||||
|
||||
# Detects internal PRs with mergeStateStatus=DIRTY and posts @claude to trigger claude.yml.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -16,7 +14,6 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# PAT posts as MervinPraison so issue_comment triggers claude.yml (GITHUB_TOKEN cannot chain workflows)
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
|
||||
permissions:
|
||||
@@ -27,145 +24,29 @@ jobs:
|
||||
detect-and-trigger:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected]
|
||||
with:
|
||||
hub_ref: v1.1.6
|
||||
- name: Detect conflicting PRs and trigger Claude
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mg = require(path.join(process.env.HUB_SCRIPTS, 'merge-gate.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const baseRepo = `${owner}/${repo}`;
|
||||
const COOLDOWN_MS = 12 * 60 * 60 * 1000;
|
||||
const AUTO_ACTORS = ['github-actions[bot]', 'MervinPraison'];
|
||||
const LABEL = 'claude-conflict-pending';
|
||||
const COMMENT_BODY = [
|
||||
'@claude this PR has merge conflicts with `main`.',
|
||||
'Please rebase onto latest `main`, resolve conflicts (keep this PR\'s intent, merge in newer main logic),',
|
||||
'run targeted tests, and force-push with `--force-with-lease`.',
|
||||
'Comment which files you resolved.',
|
||||
'Do not bloat server.py or create_app() with new params — prefer existing YAML config, plugins, and feature modules unless absolutely required.',
|
||||
'Keep the resolution minimal — the aim is a lightweight and powerful UI package; do not introduce scope creep while resolving conflicts.',
|
||||
].join(' ');
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function getMergeState(prNumber) {
|
||||
const query = `
|
||||
query($owner: String!, $repo: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) {
|
||||
mergeStateStatus
|
||||
maintainerCanModify
|
||||
isDraft
|
||||
headRef { repository { nameWithOwner } }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const result = await github.graphql(query, { owner, repo, number: prNumber });
|
||||
const prGql = result.repository.pullRequest;
|
||||
const status = (prGql?.mergeStateStatus || '').toUpperCase();
|
||||
if (status && status !== 'UNKNOWN') {
|
||||
return {
|
||||
status,
|
||||
isDraft: prGql.isDraft,
|
||||
headRepo: prGql.headRef?.repository?.nameWithOwner,
|
||||
maintainerCanModify: prGql.maintainerCanModify === true,
|
||||
};
|
||||
}
|
||||
if (attempt < 2) await sleep(10000);
|
||||
}
|
||||
const pr = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
||||
return {
|
||||
status: (pr.data.mergeable_state || '').toUpperCase(),
|
||||
isDraft: pr.data.draft,
|
||||
headRepo: pr.data.head.repo?.full_name,
|
||||
maintainerCanModify: pr.data.maintainer_can_modify === true,
|
||||
};
|
||||
const prs = await github.paginate(github.rest.pulls.list, { owner, repo, state: 'open', per_page: 50 });
|
||||
for (const pr of prs) {
|
||||
if (pr.draft) continue;
|
||||
if (pr.head?.repo?.full_name !== baseRepo) continue;
|
||||
const state = await mg.getMergeState(github, owner, repo, pr.number);
|
||||
if (state.status !== 'DIRTY') continue;
|
||||
const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number: pr.number, per_page: 100 });
|
||||
if (mg.hasRecentConflictComment(comments)) continue;
|
||||
const body = '@claude this PR has merge conflicts with `main`. Rebase onto latest main, resolve conflicts, run tests, force-push with --force-with-lease.';
|
||||
await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body });
|
||||
await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: ['claude-conflict-pending'] });
|
||||
}
|
||||
|
||||
function hasRecentAutoComment(comments) {
|
||||
const cutoff = Date.now() - COOLDOWN_MS;
|
||||
return comments.some((c) => {
|
||||
if (!AUTO_ACTORS.includes(c.user.login)) return false;
|
||||
const body = (c.body || '').toLowerCase();
|
||||
if (!body.includes('@claude') || !body.includes('merge conflict')) return false;
|
||||
return new Date(c.created_at).getTime() > cutoff;
|
||||
});
|
||||
}
|
||||
|
||||
async function processPR(prNumber) {
|
||||
const { status, isDraft, headRepo, maintainerCanModify } = await getMergeState(prNumber);
|
||||
core.info(`PR #${prNumber}: mergeState=${status}, head=${headRepo}, draft=${isDraft}, maintainerCanModify=${maintainerCanModify}`);
|
||||
|
||||
const { data: issue } = await github.rest.issues.get({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
});
|
||||
const labels = issue.labels.map((l) => l.name);
|
||||
|
||||
if (status !== 'DIRTY' && labels.includes(LABEL)) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
name: LABEL,
|
||||
});
|
||||
core.info(`Removed ${LABEL} from PR #${prNumber}`);
|
||||
}
|
||||
|
||||
if (status !== 'DIRTY') return { skipped: true, reason: 'not dirty' };
|
||||
if (isDraft) return { skipped: true, reason: 'draft' };
|
||||
if (headRepo && headRepo !== baseRepo && !maintainerCanModify) {
|
||||
return { skipped: true, reason: 'fork without maintainer edits' };
|
||||
}
|
||||
if (labels.includes(LABEL)) {
|
||||
return { skipped: true, reason: 'conflict resolution pending' };
|
||||
}
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
if (hasRecentAutoComment(comments)) {
|
||||
return { skipped: true, reason: 'recent auto comment' };
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels: [LABEL],
|
||||
});
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: COMMENT_BODY,
|
||||
});
|
||||
return { triggered: true };
|
||||
}
|
||||
|
||||
let prNumbers = [];
|
||||
if (context.eventName === 'pull_request') {
|
||||
prNumbers = [context.payload.pull_request.number];
|
||||
} else {
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
prNumbers = prs.map((p) => p.number);
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const num of prNumbers) {
|
||||
results.push({ pr: num, ...(await processPR(num)) });
|
||||
}
|
||||
core.summary.addRaw('```json\n' + JSON.stringify(results, null, 2) + '\n```');
|
||||
await core.summary.write();
|
||||
@@ -1,7 +1,5 @@
|
||||
name: Pipeline Status Sync
|
||||
|
||||
# Sync pipeline/* stage and blocker labels on open internal PRs.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/10 * * * *'
|
||||
@@ -15,28 +13,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.CLAUDE_APP_ID }}
|
||||
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- name: Sync pipeline labels on open PRs
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
await ps.syncOpenPullRequests(
|
||||
github, context.repo.owner, context.repo.repo,
|
||||
{ maxPrs: 30, dispatchMergeGate: true }, core
|
||||
);
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.6
|
||||
@@ -44,6 +44,7 @@ Thumbs.db
|
||||
# Project specific
|
||||
aiui/
|
||||
*.log
|
||||
*.err
|
||||
.praison/
|
||||
.gemini/
|
||||
.internal/
|
||||
|
||||
+13
-4
@@ -9,6 +9,14 @@ site:
|
||||
radius: "md"
|
||||
darkMode: true
|
||||
|
||||
seo:
|
||||
siteUrl: "https://ui.praison.ai"
|
||||
titleTemplate: "%s | %s"
|
||||
defaultImage: "/icon.svg"
|
||||
robots:
|
||||
index: true
|
||||
follow: true
|
||||
|
||||
# Mintlify-style navigation: few tabs, many groups per tab
|
||||
navigation:
|
||||
tabs:
|
||||
@@ -73,11 +81,12 @@ navigation:
|
||||
# Navbar actions
|
||||
navbar:
|
||||
primary:
|
||||
type: "github"
|
||||
href: "https://github.com/MervinPraison/PraisonAIUI"
|
||||
type: "button"
|
||||
label: "Get started"
|
||||
href: "/docs/getting-started/quickstart"
|
||||
links:
|
||||
- label: "Submit Issues"
|
||||
href: "https://github.com/MervinPraison/PraisonAIUI/issues"
|
||||
- label: "GitHub"
|
||||
href: "https://github.com/MervinPraison/PraisonAIUI"
|
||||
|
||||
# Footer
|
||||
footer:
|
||||
|
||||
+54
-51
@@ -1,73 +1,76 @@
|
||||
# SEO
|
||||
|
||||
Built-in SEO features for better search engine visibility.
|
||||
Built-in SEO for documentation sites — sitemap, robots, llms.txt, per-page meta tags, and crawlable static HTML.
|
||||
|
||||
## Automatic Meta Tags
|
||||
## Configuration
|
||||
|
||||
PraisonAIUI generates meta tags from your configuration:
|
||||
Top-level `seo` block in `aiui.template.yaml`:
|
||||
|
||||
```yaml
|
||||
site:
|
||||
title: "My Documentation"
|
||||
description: "Comprehensive guides for my product"
|
||||
seo:
|
||||
siteUrl: "https://ui.praison.ai"
|
||||
titleTemplate: "%s | %s"
|
||||
defaultImage: "/icon.svg"
|
||||
robots:
|
||||
index: true
|
||||
follow: true
|
||||
twitter:
|
||||
handle: "@yourhandle"
|
||||
```
|
||||
|
||||
Generates:
|
||||
```html
|
||||
<title>My Documentation</title>
|
||||
<meta name="description" content="Comprehensive guides for my product">
|
||||
```
|
||||
`siteUrl` is required for absolute canonical URLs, sitemap, and social tags.
|
||||
|
||||
## Page-Level Overrides
|
||||
## Build outputs
|
||||
|
||||
Use frontmatter to override per-page:
|
||||
`aiui build` automatically generates:
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `sitemap.xml` | All doc URLs with `lastmod` dates |
|
||||
| `robots.txt` | Crawl rules + sitemap pointer |
|
||||
| `llms.txt` | AI crawler index ([llmstxt.org](https://llmstxt.org)) |
|
||||
| `llms-full.txt` | Full markdown export for AI tools |
|
||||
| `docs/**/*.html` | Per-route HTML with meta tags + crawl nav |
|
||||
| `.nojekyll` | GitHub Pages Jekyll bypass |
|
||||
|
||||
No extra flags are needed — SEO files are emitted whenever `content.docs` is configured.
|
||||
|
||||
## Page-level overrides
|
||||
|
||||
Use frontmatter on any markdown page:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "Custom Page Title"
|
||||
description: "Custom meta description for this page"
|
||||
description: "Custom meta description for search snippets"
|
||||
noindex: true
|
||||
---
|
||||
|
||||
# Page Content
|
||||
```
|
||||
|
||||
## SEO Configuration
|
||||
If `description` is omitted, the first paragraph is used automatically.
|
||||
|
||||
## Per-page HTML includes
|
||||
|
||||
Each route HTML file contains:
|
||||
|
||||
- `<link rel="canonical">` (absolute HTTPS)
|
||||
- `<meta name="description">`
|
||||
- Open Graph tags (`og:title`, `og:description`, `og:url`, `og:type`, `og:image`)
|
||||
- Twitter Card tags
|
||||
- JSON-LD `TechArticle` schema
|
||||
- Static `<nav>` with links to every doc page (for non-JS crawlers)
|
||||
- `<noscript>` pre-rendered page content
|
||||
|
||||
## Runtime (SPA navigation)
|
||||
|
||||
When users navigate via the sidebar, React updates title, canonical, OG, Twitter, robots, and JSON-LD to match the current page.
|
||||
|
||||
## Deploy
|
||||
|
||||
GitHub Pages deploy runs via the **Deploy Docs** workflow on every push to `main`. Submit your sitemap in [Google Search Console](https://search.google.com/search-console):
|
||||
|
||||
```yaml
|
||||
site:
|
||||
seo:
|
||||
titleTemplate: "%s | My Site"
|
||||
defaultImage: "/og-image.png"
|
||||
twitterHandle: "@myhandle"
|
||||
```
|
||||
|
||||
## Open Graph
|
||||
|
||||
Automatic Open Graph tags for social sharing:
|
||||
|
||||
```html
|
||||
<meta property="og:title" content="Page Title">
|
||||
<meta property="og:description" content="Page description">
|
||||
<meta property="og:image" content="/og-image.png">
|
||||
<meta property="og:type" content="website">
|
||||
```
|
||||
|
||||
## Sitemap
|
||||
|
||||
Generate a sitemap during build:
|
||||
|
||||
```bash
|
||||
aiui build --sitemap
|
||||
```
|
||||
|
||||
Creates `sitemap.xml` with all pages.
|
||||
|
||||
## Robots.txt
|
||||
|
||||
```yaml
|
||||
site:
|
||||
seo:
|
||||
robots:
|
||||
index: true
|
||||
follow: true
|
||||
https://your-domain.com/sitemap.xml
|
||||
```
|
||||
@@ -0,0 +1,34 @@
|
||||
# Meeting Agent example — copy to .env and fill in. Never commit real secrets.
|
||||
|
||||
# Used by the PraisonAI agent for summarization/embeddings.
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# Optional: enable Slack summary delivery via the built-in SlackChannelAdapter.
|
||||
SLACK_BOT_TOKEN=
|
||||
SLACK_APP_TOKEN=
|
||||
|
||||
# Local dev server port for `python app.py`.
|
||||
MEETING_AGENT_PORT=8000
|
||||
|
||||
# ── Recall.ai (EU workspace: Praison) ───────────────────────────────────────
|
||||
# Region must match dashboard: eu-central-1 (Frankfurt).
|
||||
RECALL_REGION=eu-central-1
|
||||
# Optional: your Recall workspace ID (shown in the config-status API only; the
|
||||
# API key already scopes requests to a workspace).
|
||||
RECALL_WORKSPACE_ID=
|
||||
|
||||
# REST API key from Recall dashboard (Developers → API Keys). Server-side only.
|
||||
RECALL_API_KEY=
|
||||
|
||||
# Workspace verification secret (whsec_…) from Recall MCP or dashboard.
|
||||
RECALL_WEBHOOK_VERIFICATION_SECRET=
|
||||
|
||||
# Stable public HTTPS backend URL (reserved ngrok domain in dev). Required for
|
||||
# webhooks and Google Calendar V2 OAuth callback forwarding.
|
||||
PUBLIC_API_BASE_URL=
|
||||
|
||||
# Bot display name in meetings.
|
||||
RECALL_BOT_NAME=Praison Meeting Agent
|
||||
|
||||
# Calendar V2: when true (default), eligible future events with video links get bots.
|
||||
CALENDAR_AUTO_RECORD=true
|
||||
@@ -0,0 +1,115 @@
|
||||
# Meeting Agent (Phase 1 upload + Phase 2 Recall.ai live bots)
|
||||
|
||||
A PraisonAIUI example that wires the dashboard to **PraisonAI-Tools** meeting
|
||||
tools for upload → transcribe → summarize → index → search, plus **Recall.ai**
|
||||
Meeting Bots for live Zoom / Meet / Teams capture.
|
||||
|
||||
## What this example includes
|
||||
|
||||
| Page | Built from |
|
||||
|------|-----------|
|
||||
| Upload | `POST /api/meetings/upload` (audio → Phase 1 pipeline) |
|
||||
| Live bot | `POST /api/recall/bots` — send a Recall bot to a meeting URL |
|
||||
| Meetings | dashboard table backed by `list_meetings` |
|
||||
| Meeting detail | transcript / summary / action items + live status |
|
||||
| Calendar | Calendar V2 setup instructions + status API |
|
||||
| Chat | meeting agent with `search_meetings` + optional `schedule_recall_bot` tool |
|
||||
|
||||
## Recall.ai workspace
|
||||
|
||||
This integration targets your Recall workspace in **EU (Frankfurt)** by default:
|
||||
|
||||
- Workspace ID: set `RECALL_WORKSPACE_ID` to your own (optional — the API key
|
||||
already scopes requests to a workspace)
|
||||
- API region: `eu-central-1`
|
||||
|
||||
Follow the [Recall onboarding guide](https://docs.recall.ai/docs/agent-quickstarts) via Recall MCP (`recall://guides/onboarding`) for credential and webhook setup.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cp .env.example .env # set OPENAI_API_KEY + Recall vars (server-side only)
|
||||
pip install praisonai-tools[meeting] httpx
|
||||
aiui run app.py
|
||||
# or
|
||||
python app.py # honours MEETING_AGENT_PORT
|
||||
python app.py --smoke # non-binding Recall boundary smoke
|
||||
```
|
||||
|
||||
## Schedule a live bot
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/api/recall/bots \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"meeting_url":"https://zoom.us/j/123456789","title":"Standup"}'
|
||||
```
|
||||
|
||||
Flow: **Create Bot** → webhooks (`bot.*`, `recording.done`, `transcript.done`) →
|
||||
async Recall transcription → Phase 1 summarize + index → dashboard `ready`.
|
||||
|
||||
## Webhooks (required)
|
||||
|
||||
Set `PUBLIC_API_BASE_URL` to a stable **HTTPS** backend URL (reserved ngrok
|
||||
domain in dev — not localhost). Register in Recall:
|
||||
|
||||
- URL: `{PUBLIC_API_BASE_URL}/webhooks/recall`
|
||||
- Events: `bot.joining_call`, `bot.in_call_recording`, `bot.done`, `bot.fatal`,
|
||||
`recording.done`, `transcript.done`, `transcript.failed`, `transcript.data`
|
||||
- Calendar (Part B): `calendar.sync_events`, `calendar.update` on the same URL
|
||||
|
||||
Use Recall MCP `create_webhook_endpoint` or the dashboard. Verify with
|
||||
`send_test_webhook_endpoint`.
|
||||
|
||||
## Google Calendar V2
|
||||
|
||||
Default **CALENDAR_AUTO_RECORD=true** schedules bots for eligible future events
|
||||
with video links once Calendar V2 is connected.
|
||||
|
||||
1. Set `PUBLIC_API_BASE_URL` (HTTPS tunnel).
|
||||
2. Run Recall MCP `start_calendar_integration_setup` with
|
||||
`production_redirect_uri={PUBLIC_API_BASE_URL}/api/recall/calendar/callback`.
|
||||
3. Bootstrap the app: `POST /api/recall/calendar/setup/bootstrap` with
|
||||
`setup_id` and `regional_callback_uri` from MCP.
|
||||
4. Complete Google OAuth per `recall://guides/calendar-v2-setup-google`.
|
||||
|
||||
**Recording opt-in:** calendar sync ≠ automatic recording for every meeting;
|
||||
this app uses `CALENDAR_AUTO_RECORD` (default on). Set `false` to disable
|
||||
auto-schedule while keeping calendar connected.
|
||||
|
||||
## Environment
|
||||
|
||||
See `.env.example`:
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `OPENAI_API_KEY` | Summaries, embeddings, chat |
|
||||
| `RECALL_REGION` | `eu-central-1` |
|
||||
| `RECALL_API_KEY` | Recall REST API (backend only) |
|
||||
| `RECALL_WEBHOOK_VERIFICATION_SECRET` | Webhook signature verification |
|
||||
| `PUBLIC_API_BASE_URL` | Public HTTPS origin for webhooks + calendar callback |
|
||||
| `CALENDAR_AUTO_RECORD` | Auto-schedule bots from calendar (default `true`) |
|
||||
|
||||
Never commit real secrets. Reuse the existing REST key (`Mervin Praison`) when
|
||||
its purpose is clear; otherwise create a purpose-named key via Recall MCP.
|
||||
|
||||
## Phase 2 Part B — live transcript + calendar auto-schedule
|
||||
|
||||
| Feature | API |
|
||||
|---------|-----|
|
||||
| Live transcript (SSE) | `GET /api/meetings/{id}/live-transcript` (`Accept: text/event-stream`) |
|
||||
| Live transcript (poll) | Same URL, default JSON |
|
||||
| Upcoming calendar events | `GET /api/recall/calendar/upcoming?hours=24` |
|
||||
| Manual calendar sync | `POST /api/recall/calendar/sync` `{"calendar_id":"..."}` |
|
||||
| Agent tool | `get_upcoming_meetings(hours=24)` in chat when Recall is configured |
|
||||
|
||||
Every new bot schedule **automatically** attaches `recording_config.realtime_endpoints`
|
||||
pointing at `{PUBLIC_API_BASE_URL}/webhooks/recall` with `transcript.data` — no Recall
|
||||
dashboard toggle for live transcript. Requires `PUBLIC_API_BASE_URL` in `.env`.
|
||||
|
||||
## Tests
|
||||
|
||||
From repo root:
|
||||
|
||||
```bash
|
||||
pytest tests/unit/test_meeting_agent_example.py tests/unit/test_meeting_agent_pipeline.py tests/unit/test_meeting_agent_recall.py tests/unit/test_meeting_agent_part_b.py -v
|
||||
```
|
||||
@@ -0,0 +1,46 @@
|
||||
# Meeting Agent — PraisonAIUI example config (PAI-MEET-UI, issue #260)
|
||||
# Composes existing features (attachments, knowledge, chat, Slack) via YAML.
|
||||
# Run with: aiui run app.py
|
||||
|
||||
site:
|
||||
title: Meeting Agent
|
||||
description: Upload meetings, browse summaries and action items, ask questions.
|
||||
logo: "🗓️"
|
||||
theme:
|
||||
preset: slate
|
||||
darkMode: true
|
||||
radius: md
|
||||
|
||||
style: dashboard
|
||||
|
||||
layout:
|
||||
mode: dashboard
|
||||
|
||||
dashboard:
|
||||
sidebar: true
|
||||
pageHeader: true
|
||||
|
||||
pages:
|
||||
- upload
|
||||
- meetings
|
||||
- meeting-detail
|
||||
- chat
|
||||
|
||||
chat:
|
||||
enabled: true
|
||||
name: Meeting Assistant
|
||||
features:
|
||||
streaming: true
|
||||
reasoning: true
|
||||
tools: true
|
||||
fileUpload: true
|
||||
feedback: true
|
||||
input:
|
||||
placeholder: "Ask about a past meeting..."
|
||||
starters:
|
||||
- label: "What did we decide about billing?"
|
||||
message: "What did we decide about billing?"
|
||||
icon: "🔎"
|
||||
- label: "List open action items"
|
||||
message: "List open action items and their owners"
|
||||
icon: "✅"
|
||||
@@ -0,0 +1,733 @@
|
||||
"""Meeting Agent — PraisonAIUI example with Phase 1 upload pipeline + Recall.ai live bots.
|
||||
|
||||
Composes existing PraisonAIUI features plus PraisonAI-Tools meeting tools:
|
||||
|
||||
- Upload API → POST /api/meetings/upload (transcribe → summarize → index)
|
||||
- Live bot API → POST /api/recall/bots (Recall Meeting Bot join)
|
||||
- Recall webhooks → POST /webhooks/recall (verified, async processing)
|
||||
- Calendar callback → GET /api/recall/calendar/callback (Calendar V2 OAuth)
|
||||
- Meetings list → dashboard page backed by ``list_meetings``
|
||||
- Meeting detail → latest stored meeting (transcript / summary / actions)
|
||||
- Chat → PraisonAI Agent with ``search_meetings`` + meeting tools
|
||||
- Slack delivery → optional via SlackChannelAdapter
|
||||
|
||||
Run:
|
||||
aiui run app.py
|
||||
python app.py
|
||||
python smoke_recall.py # non-binding integration smoke
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import praisonaiui as aiui
|
||||
from praisonaiui.server import create_app
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
_EXAMPLE_DIR = Path(__file__).resolve().parent
|
||||
if str(_EXAMPLE_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLE_DIR))
|
||||
|
||||
|
||||
def _load_local_env() -> None:
|
||||
"""Load ``.env`` from the example dir when vars are not already set."""
|
||||
env_path = _EXAMPLE_DIR / ".env"
|
||||
if not env_path.is_file():
|
||||
return
|
||||
for raw in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key, value = key.strip(), value.strip()
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
_load_local_env()
|
||||
|
||||
aiui.set_custom_js(_EXAMPLE_DIR / "plugin.js")
|
||||
|
||||
from pipeline import retry_pipeline, run_ingest_pipeline
|
||||
from recall_routes import (
|
||||
api_meeting_live_transcript,
|
||||
api_recall_bot_status,
|
||||
api_recall_calendar_bootstrap,
|
||||
api_recall_calendar_callback,
|
||||
api_recall_calendar_status,
|
||||
api_recall_calendar_sync,
|
||||
api_recall_calendar_upcoming,
|
||||
api_recall_cancel_bot,
|
||||
api_recall_config_status,
|
||||
api_recall_schedule_bot,
|
||||
webhook_recall,
|
||||
)
|
||||
|
||||
CHAT_SYSTEM_PROMPT = (
|
||||
"You are a meeting assistant. For any question about past meetings, decisions, "
|
||||
"owners, or action items you MUST call the search_meetings tool first and answer "
|
||||
"only from the retrieved transcripts and summaries. Never answer historical "
|
||||
"questions from memory. Cite every claim with a citation chip showing the meeting "
|
||||
"title linked to its detail page."
|
||||
)
|
||||
|
||||
# Fallback demo rows for Slack block-shape tests and offline dashboard preview.
|
||||
SAMPLE_MEETINGS: list[dict] = [
|
||||
{
|
||||
"id": "m-1001",
|
||||
"title": "Q3 Planning Kickoff",
|
||||
"date": "2026-07-01",
|
||||
"status": "Ready",
|
||||
"duration": "48m",
|
||||
"transcript": "Alice: Let's align on Q3 goals...\nBob: Priorities are onboarding and billing.",
|
||||
"summary": "Team aligned on Q3 priorities: onboarding revamp and billing reliability.",
|
||||
"actions": [
|
||||
{"task": "Draft onboarding spec", "owner": "Alice", "due": "2026-07-08"},
|
||||
{"task": "Audit billing retries", "owner": "Bob", "due": "2026-07-10"},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
_agent: Any | None = None
|
||||
|
||||
|
||||
def _recall_schedule_tool():
|
||||
from praisonai_tools.tools.decorator import tool
|
||||
|
||||
from meeting_integrations.recall.service import schedule_recall_bot as _schedule
|
||||
|
||||
@tool
|
||||
def schedule_recall_bot(meeting_url: str, title: str = "") -> dict:
|
||||
"""Schedule a Recall.ai bot to join a meeting URL (Zoom, Meet, Teams)."""
|
||||
return _schedule(meeting_url, title)
|
||||
|
||||
return schedule_recall_bot
|
||||
|
||||
|
||||
def _upcoming_meetings_tool():
|
||||
from praisonai_tools.tools.decorator import tool
|
||||
|
||||
from meeting_integrations.recall.calendar_service import list_upcoming_calendar_meetings
|
||||
|
||||
@tool
|
||||
def get_upcoming_meetings(hours: int = 24) -> list[dict]:
|
||||
"""List upcoming calendar events with video links and bot schedule status."""
|
||||
return list_upcoming_calendar_meetings(hours=hours)
|
||||
|
||||
return get_upcoming_meetings
|
||||
|
||||
|
||||
def _load_meeting_tools():
|
||||
"""Lazy-load meeting tools (keeps import-time deps light)."""
|
||||
from praisonai_tools.tools.meeting_tools import (
|
||||
extract_action_items,
|
||||
get_meeting,
|
||||
index_meeting,
|
||||
list_meetings,
|
||||
save_meeting,
|
||||
search_meetings,
|
||||
summarize_transcript,
|
||||
transcribe_file,
|
||||
)
|
||||
|
||||
tools = [
|
||||
transcribe_file,
|
||||
save_meeting,
|
||||
get_meeting,
|
||||
list_meetings,
|
||||
summarize_transcript,
|
||||
extract_action_items,
|
||||
index_meeting,
|
||||
search_meetings,
|
||||
]
|
||||
if os.getenv("RECALL_API_KEY"):
|
||||
tools.append(_recall_schedule_tool())
|
||||
tools.append(_upcoming_meetings_tool())
|
||||
return tools
|
||||
|
||||
|
||||
def get_agent():
|
||||
"""Return the meeting agent (created lazily on first chat)."""
|
||||
global _agent
|
||||
if _agent is not None:
|
||||
return _agent
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"praisonaiagents required. Install with: pip install praisonai"
|
||||
) from exc
|
||||
|
||||
_agent = Agent(
|
||||
name="Meeting Assistant",
|
||||
instructions=CHAT_SYSTEM_PROMPT,
|
||||
model="gpt-4o-mini",
|
||||
tools=_load_meeting_tools(),
|
||||
)
|
||||
# Default loop guard kills chat after 120s while search_meetings runs.
|
||||
from praisonaiagents.escalation.loop_guard import LoopGuard, LoopGuardConfig
|
||||
|
||||
max_turn_sec = float(os.getenv("MEETING_AGENT_LOOP_GUARD_MAX_SEC", "600"))
|
||||
_agent._loop_guard = LoopGuard(
|
||||
LoopGuardConfig(enabled=True, max_time_per_turn=max_turn_sec)
|
||||
)
|
||||
return _agent
|
||||
|
||||
|
||||
def _sync_stale_recall_status(record: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Refresh Recall bot status for meetings still marked joining/scheduled."""
|
||||
meta = record.get("metadata") or {}
|
||||
live = (meta.get("live_status") or "").lower()
|
||||
bot_id = meta.get("recall_bot_id")
|
||||
if not bot_id or live in ("ready", "failed", "cancelled", "processing"):
|
||||
return record
|
||||
if live not in ("joining", "scheduled", "waiting_room", "live", "ended", ""):
|
||||
return record
|
||||
if not os.getenv("RECALL_API_KEY"):
|
||||
return record
|
||||
try:
|
||||
from meeting_integrations.recall.client import RecallClient
|
||||
from meeting_integrations.recall.config import load_recall_settings
|
||||
from pipeline import merge_meeting_metadata
|
||||
|
||||
bot = RecallClient(load_recall_settings()).get_bot(str(bot_id))
|
||||
changes = bot.get("status_changes") or []
|
||||
status = (changes[-1].get("code") if changes else bot.get("status") or "").lower()
|
||||
meeting_id = record.get("meeting_id", "")
|
||||
patch: dict[str, Any] = {}
|
||||
if status == "in_waiting_room":
|
||||
patch = {
|
||||
"live_status": "waiting_room",
|
||||
"status": "Scheduled",
|
||||
"error": (
|
||||
"Bot is in the Google Meet waiting room — admit "
|
||||
"'Praison Meeting Agent' to start recording."
|
||||
),
|
||||
}
|
||||
elif status in ("done", "call_ended") and not (bot.get("recordings") or []):
|
||||
patch = {
|
||||
"live_status": "failed",
|
||||
"status": "failed",
|
||||
"error": "Meeting ended but bot never recorded (likely not admitted from waiting room).",
|
||||
}
|
||||
elif status == "done" and (bot.get("recordings") or []):
|
||||
patch = {"live_status": "processing"}
|
||||
if patch and meeting_id:
|
||||
merge_meeting_metadata(meeting_id, patch)
|
||||
meta = {**meta, **patch}
|
||||
record = {**record, "metadata": meta}
|
||||
except Exception:
|
||||
pass
|
||||
return record
|
||||
|
||||
|
||||
def _record_to_view(record: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Normalise a tool meeting record for dashboard rendering."""
|
||||
meta = record.get("metadata") or {}
|
||||
actions = meta.get("action_items") or []
|
||||
return {
|
||||
"id": record.get("meeting_id", ""),
|
||||
"title": record.get("title", "Untitled"),
|
||||
"date": (record.get("created_at") or "")[:10],
|
||||
"status": meta.get("status", "Ready").replace("ready", "Ready").title(),
|
||||
"live_status": meta.get("live_status") or "",
|
||||
"recall_bot_id": meta.get("recall_bot_id") or "",
|
||||
"meeting_url": meta.get("meeting_url") or record.get("source") or "",
|
||||
"duration": _format_duration(meta.get("duration_seconds")),
|
||||
"transcript": meta.get("transcript") or "",
|
||||
"summary": meta.get("summary") or "",
|
||||
"error": meta.get("error") or "",
|
||||
"actions": [
|
||||
{
|
||||
"task": a.get("description", ""),
|
||||
"owner": a.get("owner") or "—",
|
||||
"due": a.get("due_date") or "—",
|
||||
}
|
||||
for a in actions
|
||||
if isinstance(a, dict)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _format_duration(seconds: Any) -> str:
|
||||
if not seconds:
|
||||
return "—"
|
||||
try:
|
||||
total = int(float(seconds))
|
||||
except (TypeError, ValueError):
|
||||
return "—"
|
||||
minutes, secs = divmod(total, 60)
|
||||
return f"{minutes}m {secs}s" if minutes else f"{secs}s"
|
||||
|
||||
|
||||
def _list_meeting_views() -> list[dict[str, Any]]:
|
||||
try:
|
||||
from praisonai_tools.tools.meeting_tools import list_meetings
|
||||
|
||||
rows = list_meetings.__wrapped__(limit=50, offset=0)
|
||||
if rows and isinstance(rows[0], dict) and "error" in rows[0]:
|
||||
return SAMPLE_MEETINGS
|
||||
rows = [_sync_stale_recall_status(r) for r in rows]
|
||||
views = [_record_to_view(r) for r in rows]
|
||||
return views or SAMPLE_MEETINGS
|
||||
except ImportError:
|
||||
return SAMPLE_MEETINGS
|
||||
|
||||
|
||||
def _meeting(meeting_id: str) -> dict | None:
|
||||
try:
|
||||
from praisonai_tools.tools.meeting_tools import get_meeting
|
||||
|
||||
record = get_meeting.__wrapped__(meeting_id)
|
||||
if "error" not in record:
|
||||
return _record_to_view(record)
|
||||
except ImportError:
|
||||
pass
|
||||
return next((m for m in SAMPLE_MEETINGS if m["id"] == meeting_id), None)
|
||||
|
||||
|
||||
def _pick_detail_meeting(meetings: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Prefer an in-progress live meeting for the detail view."""
|
||||
for live in ("live", "joining", "waiting_room"):
|
||||
for m in meetings:
|
||||
if (m.get("live_status") or "").lower() == live:
|
||||
return m
|
||||
return meetings[0] if meetings else SAMPLE_MEETINGS[0]
|
||||
|
||||
|
||||
def _status_variant(status: str) -> str:
|
||||
return {
|
||||
"Ready": "default",
|
||||
"Indexing": "secondary",
|
||||
"Summarizing": "secondary",
|
||||
"Transcribing": "secondary",
|
||||
"Uploading": "secondary",
|
||||
"Error": "destructive",
|
||||
"Failed": "destructive",
|
||||
"Scheduled": "secondary",
|
||||
"Live": "default",
|
||||
"Processing": "secondary",
|
||||
}.get(status, "outline")
|
||||
|
||||
|
||||
aiui.set_style("dashboard")
|
||||
aiui.set_branding(title="Meeting Agent", logo="🗓️")
|
||||
aiui.set_pages(
|
||||
["upload", "live-bot", "meetings", "meeting-detail", "calendar", "chat"]
|
||||
)
|
||||
aiui.set_dashboard(sidebar=True, page_header=True)
|
||||
aiui.set_chat_features(file_upload=True, feedback=True)
|
||||
|
||||
|
||||
async def _emit_pipeline_chat_result(result: dict[str, Any]) -> None:
|
||||
"""Surface pipeline progress/errors via standard chat status events."""
|
||||
if result.get("status") == "ready":
|
||||
await aiui.say(
|
||||
f"✅ **{result.get('title', 'Meeting')}** is ready. "
|
||||
f"Indexed {result.get('chunks_indexed', 0)} chunk(s)."
|
||||
)
|
||||
return
|
||||
if "error" in result:
|
||||
await aiui.say(f"❌ {result['error']}")
|
||||
meeting_id = result.get("meeting_id")
|
||||
if meeting_id:
|
||||
await aiui.action_buttons(
|
||||
[
|
||||
{
|
||||
"name": "retry-meeting",
|
||||
"label": "Retry",
|
||||
"payload": {"meeting_id": meeting_id},
|
||||
}
|
||||
]
|
||||
)
|
||||
return
|
||||
await aiui.say(str(result))
|
||||
|
||||
|
||||
async def _chat_run_pipeline(fn, *args: Any) -> None:
|
||||
await aiui.think("Running meeting pipeline...")
|
||||
result = await asyncio.to_thread(fn, *args)
|
||||
await _emit_pipeline_chat_result(result)
|
||||
|
||||
|
||||
@aiui.reply
|
||||
async def on_message(message: str):
|
||||
"""Chat: Q&A via meeting agent; retry failed uploads via chat commands."""
|
||||
text = str(message).strip()
|
||||
lower = text.lower()
|
||||
|
||||
if lower.startswith("retry meeting "):
|
||||
meeting_id = text.split(maxsplit=2)[-1].strip()
|
||||
await _chat_run_pipeline(retry_pipeline, meeting_id)
|
||||
return
|
||||
if lower.startswith("/retry "):
|
||||
meeting_id = text.split(maxsplit=1)[-1].strip()
|
||||
await _chat_run_pipeline(retry_pipeline, meeting_id)
|
||||
return
|
||||
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
await aiui.say("Set `OPENAI_API_KEY` to chat with the meeting assistant.")
|
||||
return
|
||||
|
||||
await aiui.think("Searching meetings before answering...")
|
||||
agent = get_agent()
|
||||
response = await asyncio.to_thread(agent.chat, text)
|
||||
await aiui.say(str(response))
|
||||
|
||||
|
||||
def _register_retry_action() -> None:
|
||||
from praisonaiui.actions import register_action_callback
|
||||
|
||||
async def _on_retry_meeting(action) -> None:
|
||||
meeting_id = (action.payload or {}).get("meeting_id", "")
|
||||
if not meeting_id:
|
||||
await aiui.say("Missing meeting id for retry.")
|
||||
return
|
||||
await _chat_run_pipeline(retry_pipeline, meeting_id)
|
||||
|
||||
register_action_callback("retry-meeting", _on_retry_meeting)
|
||||
|
||||
|
||||
_register_retry_action()
|
||||
|
||||
|
||||
@aiui.page("upload", title="Upload", icon="⬆️", group="Meetings", order=1)
|
||||
async def upload_page():
|
||||
steps = ["Uploading", "Transcribing", "Summarizing", "Indexing", "Ready"]
|
||||
return aiui.layout(
|
||||
[
|
||||
aiui.text("Drop an audio or transcript file to add a meeting."),
|
||||
aiui.alert(
|
||||
"POST a multipart file to /api/meetings/upload with field ``file`` "
|
||||
"(optional ``title``). Supported: mp3, mp4, wav, webm, m4a, ogg.",
|
||||
variant="info",
|
||||
title="Upload API",
|
||||
),
|
||||
aiui.code_block(
|
||||
'curl -F "[email protected]" -F "title=Weekly standup" '
|
||||
"http://127.0.0.1:8000/api/meetings/upload",
|
||||
language="bash",
|
||||
),
|
||||
aiui.card("Processing pipeline", footer=" → ".join(steps)),
|
||||
aiui.text(
|
||||
"On error the pipeline surfaces a retry action via the standard "
|
||||
"chat status events; no bespoke state machine lives in the UI. "
|
||||
"In Chat, send ``retry meeting <id>`` or click **Retry** when offered."
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@aiui.page("live-bot", title="Live bot", icon="🤖", group="Meetings", order=2)
|
||||
async def live_bot_page():
|
||||
port = os.environ.get("MEETING_AGENT_PORT", "8000")
|
||||
return aiui.layout(
|
||||
[
|
||||
aiui.text(
|
||||
"Send a Recall.ai Meeting Bot to a Zoom, Google Meet, or Teams URL. "
|
||||
"Real-time ``transcript.data`` chunks stream during the call; when the "
|
||||
"call ends, the Phase 1 summarize + index pipeline runs on the full transcript."
|
||||
),
|
||||
aiui.alert(
|
||||
"Requires RECALL_API_KEY, RECALL_WEBHOOK_VERIFICATION_SECRET, and "
|
||||
"PUBLIC_API_BASE_URL (HTTPS tunnel). Register ``transcript.data`` on the "
|
||||
"same /webhooks/recall endpoint for live captions.",
|
||||
variant="info",
|
||||
title="Recall credentials",
|
||||
),
|
||||
aiui.code_block(
|
||||
'curl -X POST http://127.0.0.1:'
|
||||
f"{port}/api/recall/bots "
|
||||
'-H "Content-Type: application/json" '
|
||||
'-d \'{"meeting_url":"https://zoom.us/j/123456789","title":"Standup"}\'',
|
||||
language="bash",
|
||||
),
|
||||
aiui.text(
|
||||
"Live transcript SSE: ``GET /api/meetings/{meeting_id}/live-transcript`` "
|
||||
"with ``Accept: text/event-stream``. Poll fallback: same URL without SSE."
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@aiui.page("calendar", title="Calendar", icon="📅", group="Meetings", order=5)
|
||||
async def calendar_page():
|
||||
upcoming: list[dict[str, Any]] = []
|
||||
status_note = "Set RECALL_API_KEY to load calendar status."
|
||||
if os.getenv("RECALL_API_KEY"):
|
||||
try:
|
||||
from meeting_integrations.recall.calendar_service import list_upcoming_calendar_meetings
|
||||
|
||||
upcoming = list_upcoming_calendar_meetings(hours=48)
|
||||
status_note = f"{len(upcoming)} upcoming event(s) with video links (48h window)."
|
||||
except Exception as exc: # noqa: BLE001
|
||||
status_note = f"Calendar API unavailable: {exc}"
|
||||
|
||||
rows = [
|
||||
[
|
||||
e.get("title", "—"),
|
||||
str(e.get("start", ""))[:16],
|
||||
e.get("platform") or "—",
|
||||
"Yes" if e.get("bot_scheduled") else "No",
|
||||
]
|
||||
for e in upcoming[:10]
|
||||
]
|
||||
return aiui.layout(
|
||||
[
|
||||
aiui.text(
|
||||
"Google Calendar V2 auto-schedules Recall bots for eligible future "
|
||||
"events with video links when CALENDAR_AUTO_RECORD=true (default)."
|
||||
),
|
||||
aiui.alert(status_note, variant="info", title="Calendar status"),
|
||||
aiui.table(
|
||||
headers=["Title", "Start", "Platform", "Bot scheduled"],
|
||||
rows=rows or [["—", "—", "—", "—"]],
|
||||
),
|
||||
aiui.alert(
|
||||
"Complete Calendar V2 setup via Recall MCP, bootstrap OAuth callback, "
|
||||
"then register calendar webhooks (calendar.sync_events, calendar.update) "
|
||||
"on /webhooks/recall.",
|
||||
variant="info",
|
||||
title="Setup",
|
||||
),
|
||||
aiui.code_block(
|
||||
"GET /api/recall/calendar/status\n"
|
||||
"GET /api/recall/calendar/upcoming?hours=24\n"
|
||||
"POST /api/recall/calendar/sync {\"calendar_id\":\"...\"}",
|
||||
language="bash",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@aiui.page("meetings", title="Meetings", icon="📋", group="Meetings", order=3)
|
||||
async def meetings_page():
|
||||
meetings = _list_meeting_views()
|
||||
rows = [
|
||||
[
|
||||
m["title"],
|
||||
m["date"],
|
||||
m["status"],
|
||||
m.get("live_status") or "—",
|
||||
m["id"],
|
||||
m["duration"],
|
||||
]
|
||||
for m in meetings
|
||||
]
|
||||
return aiui.layout(
|
||||
[
|
||||
aiui.metric("Meetings", value=len(meetings)),
|
||||
aiui.table(
|
||||
headers=["Title", "Date", "Status", "Live", "ID", "Duration"],
|
||||
rows=rows,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@aiui.page("meeting-detail", title="Meeting detail", icon="📝", group="Meetings", order=4)
|
||||
async def meeting_detail_page():
|
||||
meetings = _list_meeting_views()
|
||||
meeting = _pick_detail_meeting(meetings)
|
||||
action_rows = [[a["task"], a["owner"], a["due"]] for a in meeting["actions"]]
|
||||
live = (meeting.get("live_status") or "").lower()
|
||||
header: list[Any] = [
|
||||
aiui.text(meeting["title"]),
|
||||
aiui.badge(meeting["status"], variant=_status_variant(meeting["status"])),
|
||||
aiui.text(f"ID: {meeting['id']}"),
|
||||
]
|
||||
if live:
|
||||
variant = "default" if live == "live" else "outline"
|
||||
header.append(aiui.badge(live.title(), variant=variant))
|
||||
body: list[Any] = []
|
||||
if meeting.get("error"):
|
||||
body.append(aiui.alert(meeting["error"], variant="warning"))
|
||||
if live == "live":
|
||||
body.append(
|
||||
aiui.alert(
|
||||
"Live transcript auto-refreshes every 3 seconds on this page.",
|
||||
variant="info",
|
||||
title="Live",
|
||||
)
|
||||
)
|
||||
return aiui.layout(
|
||||
header
|
||||
+ body
|
||||
+ [
|
||||
aiui.tabs(
|
||||
[
|
||||
{
|
||||
"label": "Transcript",
|
||||
"children": [aiui.code_block(meeting["transcript"] or "(empty)", language="text")],
|
||||
},
|
||||
{
|
||||
"label": "Summary",
|
||||
"children": [aiui.text(meeting["summary"] or "(not summarised yet)")],
|
||||
},
|
||||
{
|
||||
"label": "Action items",
|
||||
"children": [
|
||||
aiui.table(
|
||||
headers=["Task", "Owner", "Due"],
|
||||
rows=action_rows or [["—", "—", "—"]],
|
||||
)
|
||||
],
|
||||
},
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _slack_adapter():
|
||||
"""Construct a Slack adapter only when tokens are configured (lazy slack_sdk)."""
|
||||
bot = os.environ.get("SLACK_BOT_TOKEN", "")
|
||||
app_token = os.environ.get("SLACK_APP_TOKEN", "")
|
||||
if not bot or not app_token:
|
||||
return None
|
||||
from praisonaiui.features.platform_adapters.slack import SlackChannelAdapter
|
||||
|
||||
return SlackChannelAdapter({"bot_token": bot, "app_token": app_token})
|
||||
|
||||
|
||||
def _summary_blocks(meeting: dict) -> list[dict]:
|
||||
action_lines = "\n".join(
|
||||
f"• {a['task']} — {a['owner']} (due {a['due']})" for a in meeting["actions"]
|
||||
) or "• (none)"
|
||||
return [
|
||||
{"type": "header", "text": {"type": "plain_text", "text": meeting["title"]}},
|
||||
{"type": "section", "text": {"type": "mrkdwn", "text": f"*Date:* {meeting['date']}"}},
|
||||
{"type": "section", "text": {"type": "mrkdwn", "text": meeting["summary"]}},
|
||||
{"type": "section", "text": {"type": "mrkdwn", "text": f"*Action items*\n{action_lines}"}},
|
||||
{
|
||||
"type": "context",
|
||||
"elements": [{"type": "mrkdwn", "text": f"Dashboard: /meeting-detail#{meeting['id']}"}],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def post_meeting_summary(meeting_id: str, channel_id: str) -> bool:
|
||||
"""Deliver a meeting summary to Slack (/meeting-summary <id>)."""
|
||||
meeting = _meeting(meeting_id)
|
||||
if meeting is None:
|
||||
return False
|
||||
adapter = _slack_adapter()
|
||||
if adapter is None:
|
||||
return False
|
||||
await adapter.start()
|
||||
try:
|
||||
await adapter.send_message(
|
||||
channel_id,
|
||||
meeting["summary"],
|
||||
blocks=_summary_blocks(meeting),
|
||||
)
|
||||
finally:
|
||||
await adapter.stop()
|
||||
return True
|
||||
|
||||
|
||||
async def api_meetings_upload(request: Request) -> JSONResponse:
|
||||
"""Accept multipart upload and run the ingest pipeline."""
|
||||
form = await request.form()
|
||||
upload = form.get("file")
|
||||
title = str(form.get("title") or "").strip() or None
|
||||
if upload is None or not getattr(upload, "filename", None):
|
||||
return JSONResponse({"error": "file field is required"}, status_code=400)
|
||||
|
||||
suffix = Path(upload.filename).suffix.lower() or ".bin"
|
||||
from pipeline import AUDIO_SUFFIXES
|
||||
|
||||
if suffix not in AUDIO_SUFFIXES:
|
||||
return JSONResponse(
|
||||
{"error": f"Unsupported file type: {suffix}. Allowed: {sorted(AUDIO_SUFFIXES)}"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
data = await upload.read()
|
||||
if not data:
|
||||
return JSONResponse({"error": "empty file"}, status_code=400)
|
||||
|
||||
uploads_dir = Path(tempfile.gettempdir()) / "praisonai_meeting_uploads"
|
||||
uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = uploads_dir / f"{os.urandom(8).hex()}{suffix}"
|
||||
dest.write_bytes(data)
|
||||
|
||||
result = await asyncio.to_thread(run_ingest_pipeline, str(dest), title)
|
||||
if "error" in result and result.get("meeting_id"):
|
||||
result["retry_hint"] = f"retry meeting {result['meeting_id']}"
|
||||
result["retry_url"] = f"/api/meetings/{result['meeting_id']}/retry"
|
||||
status = 200 if result.get("status") == "ready" else 400 if "error" in result else 500
|
||||
return JSONResponse(result, status_code=status)
|
||||
|
||||
|
||||
async def api_meeting_retry(request: Request) -> JSONResponse:
|
||||
meeting_id = request.path_params["meeting_id"]
|
||||
result = await asyncio.to_thread(retry_pipeline, meeting_id)
|
||||
if "error" in result and result.get("meeting_id"):
|
||||
result["retry_hint"] = f"retry meeting {result['meeting_id']}"
|
||||
status = 200 if result.get("status") == "ready" else 400 if "error" in result else 500
|
||||
return JSONResponse(result, status_code=status)
|
||||
|
||||
|
||||
async def api_meetings_list(_request: Request) -> JSONResponse:
|
||||
return JSONResponse({"meetings": _list_meeting_views()})
|
||||
|
||||
|
||||
async def api_meeting_detail(request: Request) -> JSONResponse:
|
||||
meeting_id = request.path_params["meeting_id"]
|
||||
meeting = _meeting(meeting_id)
|
||||
if meeting is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
return JSONResponse(meeting)
|
||||
|
||||
|
||||
app = create_app()
|
||||
_meeting_routes = [
|
||||
Route("/api/meetings/upload", api_meetings_upload, methods=["POST"]),
|
||||
Route("/api/meetings/{meeting_id}/retry", api_meeting_retry, methods=["POST"]),
|
||||
Route("/api/meetings", api_meetings_list, methods=["GET"]),
|
||||
Route("/api/meetings/{meeting_id}", api_meeting_detail, methods=["GET"]),
|
||||
Route("/api/recall/bots", api_recall_schedule_bot, methods=["POST"]),
|
||||
Route("/api/recall/bots/{meeting_id}", api_recall_bot_status, methods=["GET"]),
|
||||
Route("/api/recall/bots/{meeting_id}/cancel", api_recall_cancel_bot, methods=["POST"]),
|
||||
Route("/api/recall/config", api_recall_config_status, methods=["GET"]),
|
||||
Route("/webhooks/recall", webhook_recall, methods=["POST"]),
|
||||
Route("/api/recall/calendar/callback", api_recall_calendar_callback, methods=["GET"]),
|
||||
Route(
|
||||
"/api/recall/calendar/setup/bootstrap",
|
||||
api_recall_calendar_bootstrap,
|
||||
methods=["POST"],
|
||||
),
|
||||
Route("/api/recall/calendar/status", api_recall_calendar_status, methods=["GET"]),
|
||||
Route("/api/recall/calendar/upcoming", api_recall_calendar_upcoming, methods=["GET"]),
|
||||
Route("/api/recall/calendar/sync", api_recall_calendar_sync, methods=["POST"]),
|
||||
Route(
|
||||
"/api/meetings/{meeting_id}/live-transcript",
|
||||
api_meeting_live_transcript,
|
||||
methods=["GET"],
|
||||
),
|
||||
]
|
||||
# Prepend so custom /api/* routes win over the SPA catch-all from create_app().
|
||||
app.routes[0:0] = _meeting_routes
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--smoke" in sys.argv:
|
||||
from smoke_recall import run_smoke
|
||||
|
||||
run_smoke()
|
||||
raise SystemExit(0)
|
||||
|
||||
import uvicorn
|
||||
|
||||
host = os.environ.get("HOST", "127.0.0.1")
|
||||
port = int(os.environ.get("MEETING_AGENT_PORT", "8000"))
|
||||
uvicorn.run(app, host=host, port=port)
|
||||
@@ -0,0 +1 @@
|
||||
"""Meeting-agent integrations (Recall.ai, etc.)."""
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Recall.ai Meeting Bot integration for the meeting-agent example."""
|
||||
|
||||
from meeting_integrations.recall.client import RecallClient
|
||||
from meeting_integrations.recall.config import RecallSettings, load_recall_settings
|
||||
from meeting_integrations.recall.verify import VerificationError, verify_request_from_recall
|
||||
|
||||
__all__ = [
|
||||
"RecallClient",
|
||||
"RecallSettings",
|
||||
"VerificationError",
|
||||
"load_recall_settings",
|
||||
"verify_request_from_recall",
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Calendar V2 OAuth callback forwarding for Recall-hosted setup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FORWARD_PARAMS = ("state", "code", "error", "recall_calendar_setup_probe")
|
||||
|
||||
|
||||
def forward_calendar_callback(
|
||||
*,
|
||||
regional_callback_uri: str,
|
||||
query_params: dict[str, str],
|
||||
) -> tuple[int, str]:
|
||||
"""Forward allowed query params to Recall's regional callback URI."""
|
||||
filtered = {k: v for k, v in query_params.items() if k in FORWARD_PARAMS and v}
|
||||
if not filtered.get("state") and "recall_calendar_setup_probe" not in filtered:
|
||||
return 400, "missing state"
|
||||
|
||||
url = regional_callback_uri
|
||||
if filtered:
|
||||
url = f"{url}?{urlencode(filtered)}"
|
||||
|
||||
logger.info("Forwarding calendar callback to Recall (state redacted)")
|
||||
with httpx.Client(timeout=30.0, follow_redirects=True) as client:
|
||||
response = client.get(url)
|
||||
return response.status_code, response.text[:500]
|
||||
@@ -0,0 +1,261 @@
|
||||
"""Google Calendar V2 sync and auto-schedule via Recall Calendar API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from meeting_integrations.recall.client import RecallAPIError, RecallClient
|
||||
from meeting_integrations.recall.config import RecallSettings
|
||||
from meeting_integrations.recall.store import RecallStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_iso(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
normalized = value.replace("Z", "+00:00")
|
||||
if normalized.endswith("+0000"):
|
||||
normalized = normalized[:-5] + "+00:00"
|
||||
return datetime.fromisoformat(normalized)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _event_title(event: dict[str, Any]) -> str:
|
||||
raw = event.get("raw") or {}
|
||||
if isinstance(raw, dict):
|
||||
summary = raw.get("summary") or raw.get("subject")
|
||||
if summary:
|
||||
return str(summary)
|
||||
return "Calendar meeting"
|
||||
|
||||
|
||||
def _deduplication_key(event: dict[str, Any]) -> str:
|
||||
start = str(event.get("start_time") or "")
|
||||
url = str(event.get("meeting_url") or "")
|
||||
return f"{start}-{url}"
|
||||
|
||||
|
||||
def is_eligible_for_auto_record(event: dict[str, Any]) -> bool:
|
||||
"""Return True when Recall should schedule a bot for this calendar event."""
|
||||
if event.get("is_deleted"):
|
||||
return False
|
||||
if not event.get("meeting_url"):
|
||||
return False
|
||||
end = _parse_iso(str(event.get("end_time") or ""))
|
||||
if end and end < datetime.now(timezone.utc):
|
||||
return False
|
||||
raw = event.get("raw") or {}
|
||||
if isinstance(raw, dict):
|
||||
status = str(raw.get("status") or "").lower()
|
||||
if status == "cancelled":
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def list_upcoming_calendar_meetings(
|
||||
*,
|
||||
hours: int = 24,
|
||||
settings: RecallSettings | None = None,
|
||||
client: RecallClient | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List upcoming Recall calendar events with video links (for UI + agent tool)."""
|
||||
from meeting_integrations.recall.config import load_recall_settings
|
||||
|
||||
settings = settings or load_recall_settings()
|
||||
client = client or RecallClient(settings)
|
||||
|
||||
try:
|
||||
events = client.list_calendar_events(is_deleted=False)
|
||||
except RecallAPIError as exc:
|
||||
logger.warning("Calendar events unavailable: %s", exc)
|
||||
return []
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
horizon = now + timedelta(hours=max(1, hours))
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for event in events:
|
||||
if not is_eligible_for_auto_record(event):
|
||||
continue
|
||||
start = _parse_iso(str(event.get("start_time") or ""))
|
||||
if start and start > horizon:
|
||||
continue
|
||||
bots = event.get("bots") or []
|
||||
rows.append(
|
||||
{
|
||||
"event_id": event.get("id"),
|
||||
"title": _event_title(event),
|
||||
"start": event.get("start_time"),
|
||||
"end": event.get("end_time"),
|
||||
"platform": (event.get("meeting_platform") or {}).get("name")
|
||||
if isinstance(event.get("meeting_platform"), dict)
|
||||
else event.get("meeting_platform"),
|
||||
"meeting_url": event.get("meeting_url"),
|
||||
"bot_scheduled": bool(bots),
|
||||
"calendar_id": event.get("calendar_id"),
|
||||
}
|
||||
)
|
||||
|
||||
rows.sort(key=lambda r: str(r.get("start") or ""))
|
||||
return rows
|
||||
|
||||
|
||||
def _ensure_local_meeting_for_event(
|
||||
event: dict[str, Any],
|
||||
*,
|
||||
settings: RecallSettings,
|
||||
) -> str:
|
||||
"""Create or reuse a local meeting row for a calendar event."""
|
||||
event_id = str(event.get("id") or "")
|
||||
if RecallStore.is_event_scheduled(event_id):
|
||||
row = RecallStore.get_scheduled_bot(event_id)
|
||||
if row and row.get("meeting_id"):
|
||||
return str(row["meeting_id"])
|
||||
|
||||
from praisonai_tools.tools.meeting_tools import save_meeting
|
||||
|
||||
title = _event_title(event)
|
||||
url = str(event.get("meeting_url") or "")
|
||||
saved = save_meeting.__wrapped__(
|
||||
title=title,
|
||||
source=url,
|
||||
metadata={
|
||||
"status": "scheduled",
|
||||
"live_status": "scheduled",
|
||||
"meeting_url": url,
|
||||
"source": "calendar",
|
||||
"recall_calendar_event_id": event_id,
|
||||
},
|
||||
)
|
||||
if "error" in saved:
|
||||
raise RecallAPIError(0, saved["error"])
|
||||
return str(saved["meeting_id"])
|
||||
|
||||
|
||||
def sync_calendar_events(
|
||||
calendar_id: str,
|
||||
*,
|
||||
updated_since: str | None = None,
|
||||
settings: RecallSettings,
|
||||
client: RecallClient | None = None,
|
||||
auto_record: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch changed calendar events and schedule/remove bots per policy."""
|
||||
client = client or RecallClient(settings)
|
||||
should_record = (
|
||||
settings.calendar_auto_record if auto_record is None else auto_record
|
||||
)
|
||||
|
||||
events = client.list_calendar_events(
|
||||
calendar_id=calendar_id,
|
||||
updated_at_gte=updated_since,
|
||||
)
|
||||
|
||||
scheduled = 0
|
||||
removed = 0
|
||||
skipped = 0
|
||||
errors: list[str] = []
|
||||
|
||||
for event in events:
|
||||
event_id = str(event.get("id") or "")
|
||||
if not event_id:
|
||||
continue
|
||||
|
||||
eligible = is_eligible_for_auto_record(event) and should_record
|
||||
has_bots = bool(event.get("bots"))
|
||||
|
||||
if not eligible:
|
||||
if has_bots and (event.get("is_deleted") or not should_record):
|
||||
try:
|
||||
client.remove_bot_from_calendar_event(event_id)
|
||||
removed += 1
|
||||
except RecallAPIError as exc:
|
||||
errors.append(f"{event_id}: remove failed: {exc.detail}")
|
||||
else:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
if RecallStore.is_event_scheduled(event_id) and has_bots:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
meeting_id = _ensure_local_meeting_for_event(event, settings=settings)
|
||||
bot_config = client.build_bot_config(
|
||||
meeting_id=meeting_id,
|
||||
title=_event_title(event),
|
||||
)
|
||||
updated = client.schedule_bot_for_calendar_event(
|
||||
event_id,
|
||||
deduplication_key=_deduplication_key(event),
|
||||
bot_config=bot_config,
|
||||
)
|
||||
bot_id = None
|
||||
bots = updated.get("bots") or []
|
||||
if bots and isinstance(bots[0], dict):
|
||||
bot_id = bots[0].get("bot_id") or bots[0].get("id")
|
||||
RecallStore.record_scheduled_bot(event_id, meeting_id, bot_id)
|
||||
from pipeline import merge_meeting_metadata
|
||||
|
||||
merge_meeting_metadata(
|
||||
meeting_id,
|
||||
{
|
||||
"recall_calendar_event_id": event_id,
|
||||
"recall_bot_id": bot_id,
|
||||
"status": "scheduled",
|
||||
"live_status": "scheduled",
|
||||
},
|
||||
)
|
||||
scheduled += 1
|
||||
except RecallAPIError as exc:
|
||||
logger.warning("Calendar schedule failed for %s: %s", event_id, exc.detail)
|
||||
errors.append(f"{event_id}: {exc.detail}")
|
||||
|
||||
return {
|
||||
"calendar_id": calendar_id,
|
||||
"fetched": len(events),
|
||||
"scheduled": scheduled,
|
||||
"removed": removed,
|
||||
"skipped": skipped,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
def process_calendar_webhook(
|
||||
event_type: str,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
settings: RecallSettings,
|
||||
client: RecallClient | None = None,
|
||||
) -> None:
|
||||
"""Handle Recall Calendar V2 webhooks (calendar.update, calendar.sync_events)."""
|
||||
data = payload.get("data") or {}
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
|
||||
calendar_id = str(data.get("calendar_id") or "")
|
||||
if not calendar_id:
|
||||
logger.warning("Calendar webhook %s missing calendar_id", event_type)
|
||||
return
|
||||
|
||||
if event_type == "calendar.update":
|
||||
logger.info("Calendar %s updated — refresh status in UI", calendar_id)
|
||||
return
|
||||
|
||||
if event_type == "calendar.sync_events":
|
||||
updated_since = str(data.get("last_updated_ts") or "") or None
|
||||
result = sync_calendar_events(
|
||||
calendar_id,
|
||||
updated_since=updated_since,
|
||||
settings=settings,
|
||||
client=client,
|
||||
)
|
||||
logger.info("Calendar sync %s: %s", calendar_id, result)
|
||||
return
|
||||
|
||||
logger.debug("Ignoring calendar webhook event %s", event_type)
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Minimal Recall.ai REST client (EU region, v1.11)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from meeting_integrations.recall.config import RecallSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RecallAPIError(RuntimeError):
|
||||
"""Recall API returned a non-success response."""
|
||||
|
||||
def __init__(self, status_code: int, detail: str):
|
||||
super().__init__(detail)
|
||||
self.status_code = status_code
|
||||
self.detail = detail
|
||||
|
||||
|
||||
class RecallClient:
|
||||
"""HTTP client for Recall Meeting Bots and async transcription."""
|
||||
|
||||
def __init__(self, settings: RecallSettings):
|
||||
self._settings = settings
|
||||
self._base = settings.api_base.rstrip("/")
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": self._settings.api_key,
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
json_body: dict[str, Any] | None = None,
|
||||
max_attempts: int = 4,
|
||||
) -> dict[str, Any]:
|
||||
url = f"{self._base}{path}"
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
response = client.request(
|
||||
method,
|
||||
url,
|
||||
headers=self._headers(),
|
||||
json=json_body,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
last_error = exc
|
||||
if attempt + 1 >= max_attempts:
|
||||
raise RecallAPIError(0, str(exc)) from exc
|
||||
time.sleep(min(2**attempt + random.random(), 8))
|
||||
continue
|
||||
|
||||
if response.status_code == 429:
|
||||
retry_after = response.headers.get("Retry-After")
|
||||
delay = float(retry_after) if retry_after else min(2**attempt, 30)
|
||||
time.sleep(delay + random.random())
|
||||
continue
|
||||
|
||||
if response.status_code in {503, 507} and attempt + 1 < max_attempts:
|
||||
time.sleep(min(2**attempt + random.random(), 8))
|
||||
continue
|
||||
|
||||
if response.status_code >= 400:
|
||||
detail = response.text[:500] or response.reason_phrase
|
||||
raise RecallAPIError(response.status_code, detail)
|
||||
|
||||
if not response.content:
|
||||
return {}
|
||||
return response.json()
|
||||
|
||||
raise RecallAPIError(0, str(last_error or "request failed"))
|
||||
|
||||
def create_bot(
|
||||
self,
|
||||
*,
|
||||
meeting_url: str,
|
||||
meeting_id: str,
|
||||
title: str,
|
||||
join_at: str | None = None,
|
||||
enable_realtime_transcription: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Schedule a Recall bot; attach local meeting_id in metadata for webhook routing."""
|
||||
body: dict[str, Any] = {
|
||||
"meeting_url": meeting_url,
|
||||
"bot_name": self._settings.bot_name,
|
||||
"metadata": {"meeting_id": meeting_id, "title": title},
|
||||
}
|
||||
if join_at:
|
||||
body["join_at"] = join_at
|
||||
if enable_realtime_transcription:
|
||||
recording_config = self._settings.realtime_recording_config()
|
||||
if recording_config:
|
||||
body["recording_config"] = recording_config
|
||||
logger.info(
|
||||
"Live transcript enabled for meeting_id=%s → %s",
|
||||
meeting_id,
|
||||
self._settings.webhook_url,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Live transcript skipped for meeting_id=%s — set PUBLIC_API_BASE_URL",
|
||||
meeting_id,
|
||||
)
|
||||
logger.info(
|
||||
"Creating Recall bot for meeting_id=%s (url redacted)",
|
||||
meeting_id,
|
||||
)
|
||||
return self._request("POST", "/api/v1/bot/", json_body=body)
|
||||
|
||||
def build_bot_config(
|
||||
self,
|
||||
*,
|
||||
meeting_id: str,
|
||||
title: str,
|
||||
enable_realtime_transcription: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Bot config payload for Calendar V2 schedule-bot requests."""
|
||||
config: dict[str, Any] = {
|
||||
"bot_name": self._settings.bot_name,
|
||||
"metadata": {"meeting_id": meeting_id, "title": title},
|
||||
}
|
||||
if enable_realtime_transcription:
|
||||
recording_config = self._settings.realtime_recording_config()
|
||||
if recording_config:
|
||||
config["recording_config"] = recording_config
|
||||
return config
|
||||
|
||||
def get_bot(self, bot_id: str) -> dict[str, Any]:
|
||||
return self._request("GET", f"/api/v1/bot/{bot_id}/")
|
||||
|
||||
def cancel_bot(self, bot_id: str) -> dict[str, Any]:
|
||||
return self._request("POST", f"/api/v1/bot/{bot_id}/leave_call/")
|
||||
|
||||
def create_async_transcript(self, recording_id: str) -> dict[str, Any]:
|
||||
body = {
|
||||
"provider": {"recallai_async": {"language_code": "auto"}},
|
||||
"diarization": {"use_separate_streams_when_available": True},
|
||||
}
|
||||
return self._request(
|
||||
"POST",
|
||||
f"/api/v1/recording/{recording_id}/create_transcript/",
|
||||
json_body=body,
|
||||
)
|
||||
|
||||
def get_transcript(self, transcript_id: str) -> dict[str, Any]:
|
||||
return self._request("GET", f"/api/v1/transcript/{transcript_id}/")
|
||||
|
||||
@staticmethod
|
||||
def extract_recording_id(payload: dict[str, Any]) -> str | None:
|
||||
data = payload.get("data") or {}
|
||||
if isinstance(data, dict):
|
||||
recording = data.get("recording") or {}
|
||||
if isinstance(recording, dict) and recording.get("id"):
|
||||
return str(recording["id"])
|
||||
inner = data.get("data") or {}
|
||||
if isinstance(inner, dict):
|
||||
rec = inner.get("recording") or {}
|
||||
if isinstance(rec, dict) and rec.get("id"):
|
||||
return str(rec["id"])
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def extract_transcript_id(payload: dict[str, Any]) -> str | None:
|
||||
data = payload.get("data") or {}
|
||||
if isinstance(data, dict):
|
||||
transcript = data.get("transcript") or {}
|
||||
if isinstance(transcript, dict) and transcript.get("id"):
|
||||
return str(transcript["id"])
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def extract_bot_id(payload: dict[str, Any]) -> str | None:
|
||||
data = payload.get("data") or {}
|
||||
if isinstance(data, dict):
|
||||
bot = data.get("bot") or {}
|
||||
if isinstance(bot, dict) and bot.get("id"):
|
||||
return str(bot["id"])
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def extract_meeting_id_from_bot_metadata(payload: dict[str, Any]) -> str | None:
|
||||
data = payload.get("data") or {}
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
bot = data.get("bot") or {}
|
||||
if not isinstance(bot, dict):
|
||||
return None
|
||||
metadata = bot.get("metadata") or {}
|
||||
if isinstance(metadata, dict):
|
||||
mid = metadata.get("meeting_id")
|
||||
return str(mid) if mid else None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def download_json(url: str) -> Any:
|
||||
with httpx.Client(timeout=60.0) as client:
|
||||
response = client.get(url)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def list_calendars(self, *, status: str | None = "connected") -> list[dict[str, Any]]:
|
||||
params: dict[str, str] = {}
|
||||
if status:
|
||||
params["status"] = status
|
||||
results: list[dict[str, Any]] = []
|
||||
cursor: str | None = None
|
||||
while True:
|
||||
query = dict(params)
|
||||
if cursor:
|
||||
query["cursor"] = cursor
|
||||
path = "/api/v2/calendars/"
|
||||
if query:
|
||||
from urllib.parse import urlencode
|
||||
|
||||
path = f"{path}?{urlencode(query)}"
|
||||
page = self._request("GET", path)
|
||||
batch = page.get("results") or []
|
||||
if isinstance(batch, list):
|
||||
results.extend(item for item in batch if isinstance(item, dict))
|
||||
cursor = page.get("next")
|
||||
if not cursor:
|
||||
break
|
||||
return results
|
||||
|
||||
def list_calendar_events(
|
||||
self,
|
||||
*,
|
||||
calendar_id: str | None = None,
|
||||
updated_at_gte: str | None = None,
|
||||
is_deleted: bool | None = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
from urllib.parse import urlencode
|
||||
|
||||
params: dict[str, str] = {}
|
||||
if calendar_id:
|
||||
params["calendar_id"] = calendar_id
|
||||
if updated_at_gte:
|
||||
params["updated_at__gte"] = updated_at_gte
|
||||
if is_deleted is not None:
|
||||
params["is_deleted"] = "true" if is_deleted else "false"
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
cursor: str | None = None
|
||||
while True:
|
||||
query = dict(params)
|
||||
if cursor:
|
||||
query["cursor"] = cursor
|
||||
path = f"/api/v2/calendar-events/?{urlencode(query)}" if query else "/api/v2/calendar-events/"
|
||||
page = self._request("GET", path)
|
||||
batch = page.get("results") or []
|
||||
if isinstance(batch, list):
|
||||
results.extend(item for item in batch if isinstance(item, dict))
|
||||
cursor = page.get("next")
|
||||
if not cursor:
|
||||
break
|
||||
return results
|
||||
|
||||
def schedule_bot_for_calendar_event(
|
||||
self,
|
||||
event_id: str,
|
||||
*,
|
||||
deduplication_key: str,
|
||||
bot_config: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
body = {
|
||||
"deduplication_key": deduplication_key,
|
||||
"bot_config": bot_config,
|
||||
}
|
||||
return self._request(
|
||||
"POST",
|
||||
f"/api/v2/calendar-events/{event_id}/bot/",
|
||||
json_body=body,
|
||||
)
|
||||
|
||||
def remove_bot_from_calendar_event(self, event_id: str) -> dict[str, Any]:
|
||||
return self._request("DELETE", f"/api/v2/calendar-events/{event_id}/bot/")
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Recall.ai runtime configuration — credentials stay server-side only."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
class RecallConfigError(ValueError):
|
||||
"""Raised when required Recall settings are missing or invalid."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecallSettings:
|
||||
"""Validated Recall workspace settings injected into the API client."""
|
||||
|
||||
region: str
|
||||
api_key: str
|
||||
webhook_verification_secret: str
|
||||
public_api_base_url: str
|
||||
workspace_id: str
|
||||
bot_name: str
|
||||
calendar_auto_record: bool
|
||||
|
||||
@property
|
||||
def api_base(self) -> str:
|
||||
return f"https://{self.region}.recall.ai"
|
||||
|
||||
@property
|
||||
def webhook_url(self) -> str:
|
||||
return f"{self.public_api_base_url.rstrip('/')}/webhooks/recall"
|
||||
|
||||
@property
|
||||
def calendar_callback_url(self) -> str:
|
||||
return f"{self.public_api_base_url.rstrip('/')}/api/recall/calendar/callback"
|
||||
|
||||
def realtime_recording_config(self) -> dict[str, Any] | None:
|
||||
"""Recording config with live ``transcript.data`` webhooks (no dashboard toggle)."""
|
||||
if not self.public_api_base_url:
|
||||
return None
|
||||
return {
|
||||
"transcript": {
|
||||
"provider": {
|
||||
"recallai_streaming": {
|
||||
"mode": "prioritize_low_latency",
|
||||
"language_code": "en",
|
||||
}
|
||||
},
|
||||
"diarization": {
|
||||
"use_separate_streams_when_available": True,
|
||||
},
|
||||
},
|
||||
"realtime_endpoints": [
|
||||
{
|
||||
"type": "webhook",
|
||||
"url": self.webhook_url,
|
||||
"events": ["transcript.data", "transcript.partial_data"],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _require(name: str) -> str:
|
||||
value = os.getenv(name, "").strip()
|
||||
if not value:
|
||||
raise RecallConfigError(f"{name} is required for Recall integration")
|
||||
return value
|
||||
|
||||
|
||||
def load_recall_settings(*, require_public_url: bool = False) -> RecallSettings:
|
||||
"""Load Recall settings from the process environment."""
|
||||
region = os.getenv("RECALL_REGION", "eu-central-1").strip() or "eu-central-1"
|
||||
public_url = os.getenv("PUBLIC_API_BASE_URL", "").strip()
|
||||
if require_public_url and not public_url:
|
||||
raise RecallConfigError(
|
||||
"PUBLIC_API_BASE_URL is required (stable HTTPS backend URL for webhooks "
|
||||
"and calendar OAuth callback forwarding)"
|
||||
)
|
||||
if public_url:
|
||||
parsed = urlparse(public_url)
|
||||
if parsed.scheme not in {"https", "http"} or not parsed.netloc:
|
||||
raise RecallConfigError("PUBLIC_API_BASE_URL must be an absolute http(s) URL")
|
||||
if parsed.hostname in {"localhost", "127.0.0.1"}:
|
||||
raise RecallConfigError(
|
||||
"PUBLIC_API_BASE_URL must not be localhost; use a reserved tunnel domain"
|
||||
)
|
||||
|
||||
auto_record = os.getenv("CALENDAR_AUTO_RECORD", "true").strip().lower()
|
||||
return RecallSettings(
|
||||
region=region,
|
||||
api_key=_require("RECALL_API_KEY"),
|
||||
webhook_verification_secret=_require("RECALL_WEBHOOK_VERIFICATION_SECRET"),
|
||||
public_api_base_url=public_url,
|
||||
workspace_id=os.getenv("RECALL_WORKSPACE_ID", "").strip(),
|
||||
bot_name=os.getenv("RECALL_BOT_NAME", "Praison Meeting Agent").strip()
|
||||
or "Praison Meeting Agent",
|
||||
calendar_auto_record=auto_record not in {"0", "false", "no", "off"},
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
"""In-process live transcript fan-out for SSE subscribers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class LiveTranscriptState:
|
||||
"""Latest live transcript snapshot for a meeting."""
|
||||
|
||||
lines: list[str] = field(default_factory=list)
|
||||
text: str = ""
|
||||
live_status: str = ""
|
||||
|
||||
|
||||
_lock = threading.Lock()
|
||||
_states: dict[str, LiveTranscriptState] = {}
|
||||
_subscribers: dict[str, list[threading.Event]] = defaultdict(list)
|
||||
|
||||
|
||||
def get_live_state(meeting_id: str) -> LiveTranscriptState | None:
|
||||
with _lock:
|
||||
return _states.get(meeting_id)
|
||||
|
||||
|
||||
def publish_live_chunk(
|
||||
meeting_id: str,
|
||||
*,
|
||||
line: str,
|
||||
full_text: str,
|
||||
live_status: str = "live",
|
||||
) -> None:
|
||||
"""Record a new utterance and notify SSE waiters."""
|
||||
with _lock:
|
||||
state = _states.setdefault(meeting_id, LiveTranscriptState())
|
||||
if line:
|
||||
state.lines.append(line)
|
||||
state.text = full_text
|
||||
state.live_status = live_status
|
||||
waiters = list(_subscribers.get(meeting_id, []))
|
||||
for event in waiters:
|
||||
event.set()
|
||||
|
||||
|
||||
def set_live_status(meeting_id: str, live_status: str) -> None:
|
||||
with _lock:
|
||||
state = _states.setdefault(meeting_id, LiveTranscriptState())
|
||||
state.live_status = live_status
|
||||
waiters = list(_subscribers.get(meeting_id, []))
|
||||
for event in waiters:
|
||||
event.set()
|
||||
|
||||
|
||||
def wait_for_update(meeting_id: str, timeout: float = 25.0) -> LiveTranscriptState | None:
|
||||
"""Block until the meeting transcript changes or timeout (for SSE long-poll)."""
|
||||
event = threading.Event()
|
||||
with _lock:
|
||||
_subscribers[meeting_id].append(event)
|
||||
state = _states.get(meeting_id)
|
||||
event.wait(timeout=timeout)
|
||||
with _lock:
|
||||
subs = _subscribers.get(meeting_id, [])
|
||||
if event in subs:
|
||||
subs.remove(event)
|
||||
return _states.get(meeting_id)
|
||||
|
||||
|
||||
def snapshot_for_api(meeting_id: str, record: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Merge in-memory live state with persisted meeting metadata."""
|
||||
meta = (record or {}).get("metadata") or {}
|
||||
live = get_live_state(meeting_id)
|
||||
text = (live.text if live and live.text else meta.get("transcript")) or ""
|
||||
return {
|
||||
"meeting_id": meeting_id,
|
||||
"live_status": (live.live_status if live and live.live_status else meta.get("live_status"))
|
||||
or "",
|
||||
"transcript": text,
|
||||
"line_count": len([ln for ln in text.splitlines() if ln.strip()]),
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Async Recall webhook event processing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from meeting_integrations.recall.client import RecallAPIError, RecallClient
|
||||
from meeting_integrations.recall.config import RecallSettings
|
||||
from meeting_integrations.recall.store import RecallStore, webhook_event_key
|
||||
from meeting_integrations.recall.transcript import transcript_data_to_line, transcript_download_to_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _speaker_prefix(line: str) -> str:
|
||||
if ":" in line:
|
||||
return line.split(":", 1)[0] + ":"
|
||||
return ""
|
||||
|
||||
|
||||
def _upsert_live_transcript(meeting_id: str, line: str, *, partial: bool = False) -> str:
|
||||
"""Merge one live caption line; partial updates replace the last same-speaker line."""
|
||||
from praisonai_tools.tools.meeting_tools import get_meeting
|
||||
from meeting_integrations.recall.live import publish_live_chunk
|
||||
from pipeline import _metadata_lock
|
||||
|
||||
# Hold the merge lock across the read too: streaming transcript events are
|
||||
# dispatched one-thread-each, so an unsynchronized read-append-write would
|
||||
# let concurrent captions clobber each other and drop lines.
|
||||
with _metadata_lock:
|
||||
record = get_meeting.__wrapped__(meeting_id)
|
||||
meta = record.get("metadata") or {} if isinstance(record, dict) else {}
|
||||
existing = str(meta.get("transcript") or "")
|
||||
lines = existing.splitlines()
|
||||
prefix = _speaker_prefix(line)
|
||||
|
||||
if lines and lines[-1] == line:
|
||||
full = existing
|
||||
elif partial and lines and prefix and lines[-1].startswith(prefix):
|
||||
lines[-1] = line
|
||||
full = "\n".join(lines)
|
||||
elif not partial and lines and prefix and lines[-1].startswith(prefix):
|
||||
# Final utterance replaces the in-progress partial for this speaker.
|
||||
lines[-1] = line
|
||||
full = "\n".join(lines)
|
||||
else:
|
||||
full = f"{existing}\n{line}".strip() if existing else line
|
||||
|
||||
_merge_metadata(
|
||||
meeting_id,
|
||||
{"transcript": full, "live_status": "live", "status": "live"},
|
||||
)
|
||||
publish_live_chunk(meeting_id, line=line, full_text=full, live_status="live")
|
||||
return full
|
||||
|
||||
|
||||
def _append_live_transcript(meeting_id: str, line: str) -> str:
|
||||
return _upsert_live_transcript(meeting_id, line, partial=False)
|
||||
|
||||
|
||||
def _merge_metadata(meeting_id: str, patch: dict[str, Any]) -> None:
|
||||
from pipeline import merge_meeting_metadata
|
||||
|
||||
merge_meeting_metadata(meeting_id, patch)
|
||||
|
||||
|
||||
def _resolve_meeting_id(payload: dict[str, Any], bot_id: str | None) -> str | None:
|
||||
meeting_id = RecallClient.extract_meeting_id_from_bot_metadata(payload)
|
||||
if meeting_id:
|
||||
return meeting_id
|
||||
if not bot_id:
|
||||
return None
|
||||
try:
|
||||
from praisonai_tools.tools.meeting_tools import list_meetings
|
||||
|
||||
rows = list_meetings.__wrapped__(limit=200, offset=0)
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
meta = row.get("metadata") or {}
|
||||
if meta.get("recall_bot_id") == bot_id:
|
||||
return row.get("meeting_id")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Could not resolve meeting by bot_id: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def process_recall_webhook(
|
||||
event_type: str,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
settings: RecallSettings,
|
||||
client: RecallClient | None = None,
|
||||
) -> None:
|
||||
"""Handle a verified Recall webhook (runs off the request thread)."""
|
||||
event_key = webhook_event_key(event_type, payload)
|
||||
if not RecallStore.mark_event_processed(event_key, event_type):
|
||||
logger.info("Skipping duplicate Recall event %s", event_type)
|
||||
return
|
||||
|
||||
client = client or RecallClient(settings)
|
||||
bot_id = RecallClient.extract_bot_id(payload)
|
||||
meeting_id = _resolve_meeting_id(payload, bot_id)
|
||||
|
||||
if event_type == "bot.joining_call" and meeting_id:
|
||||
_merge_metadata(meeting_id, {"live_status": "joining", "recall_bot_id": bot_id})
|
||||
return
|
||||
|
||||
if event_type == "bot.in_waiting_room" and meeting_id:
|
||||
_merge_metadata(
|
||||
meeting_id,
|
||||
{
|
||||
"live_status": "waiting_room",
|
||||
"status": "Scheduled",
|
||||
"error": "Bot is in the meeting waiting room — admit 'Praison Meeting Agent' to record.",
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if event_type == "bot.in_call_recording" and meeting_id:
|
||||
_merge_metadata(meeting_id, {"live_status": "live", "status": "live", "error": ""})
|
||||
from meeting_integrations.recall.live import set_live_status
|
||||
|
||||
set_live_status(meeting_id, "live")
|
||||
return
|
||||
|
||||
if event_type == "transcript.data" and meeting_id:
|
||||
line = transcript_data_to_line(payload)
|
||||
if line:
|
||||
_append_live_transcript(meeting_id, line)
|
||||
return
|
||||
|
||||
if event_type == "transcript.partial_data" and meeting_id:
|
||||
line = transcript_data_to_line(payload)
|
||||
if line:
|
||||
_upsert_live_transcript(meeting_id, line, partial=True)
|
||||
return
|
||||
|
||||
if event_type == "bot.call_ended" and meeting_id:
|
||||
_merge_metadata(meeting_id, {"live_status": "ended"})
|
||||
from meeting_integrations.recall.live import set_live_status
|
||||
|
||||
set_live_status(meeting_id, "ended")
|
||||
return
|
||||
|
||||
if event_type == "bot.fatal" and meeting_id:
|
||||
sub = ((payload.get("data") or {}).get("data") or {}).get("sub_code")
|
||||
_merge_metadata(
|
||||
meeting_id,
|
||||
{
|
||||
"live_status": "failed",
|
||||
"status": "failed",
|
||||
"error": f"Recall bot fatal: {sub or 'unknown'}",
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if event_type == "bot.done" and meeting_id:
|
||||
_merge_metadata(meeting_id, {"live_status": "processing"})
|
||||
return
|
||||
|
||||
if event_type == "recording.done":
|
||||
recording_id = RecallClient.extract_recording_id(payload)
|
||||
if not recording_id:
|
||||
logger.warning("recording.done without recording id")
|
||||
return
|
||||
if meeting_id:
|
||||
_merge_metadata(meeting_id, {"recall_recording_id": recording_id})
|
||||
started_at = ((payload.get("data") or {}).get("recording") or {}).get("started_at")
|
||||
completed_at = ((payload.get("data") or {}).get("recording") or {}).get("completed_at")
|
||||
if started_at and completed_at:
|
||||
try:
|
||||
from datetime import datetime
|
||||
|
||||
fmt = "%Y-%m-%dT%H:%M:%S.%f%z"
|
||||
start = datetime.strptime(started_at.replace("Z", "+0000"), fmt)
|
||||
end = datetime.strptime(completed_at.replace("Z", "+0000"), fmt)
|
||||
_merge_metadata(
|
||||
meeting_id,
|
||||
{"duration_seconds": int((end - start).total_seconds())},
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
client.create_async_transcript(recording_id)
|
||||
logger.info("Started async transcript for recording %s", recording_id)
|
||||
except RecallAPIError as exc:
|
||||
logger.error("create_async_transcript failed: %s", exc)
|
||||
if meeting_id:
|
||||
_merge_metadata(
|
||||
meeting_id,
|
||||
{"status": "failed", "error": f"Transcript start failed: {exc}"},
|
||||
)
|
||||
return
|
||||
|
||||
if event_type == "transcript.failed" and meeting_id:
|
||||
_merge_metadata(
|
||||
meeting_id,
|
||||
{"status": "failed", "error": "Recall transcript generation failed"},
|
||||
)
|
||||
return
|
||||
|
||||
if event_type == "transcript.done":
|
||||
transcript_id = RecallClient.extract_transcript_id(payload)
|
||||
if not transcript_id:
|
||||
logger.warning("transcript.done without transcript id")
|
||||
return
|
||||
if not meeting_id:
|
||||
logger.warning("transcript.done without meeting_id")
|
||||
return
|
||||
try:
|
||||
artifact = client.get_transcript(transcript_id)
|
||||
download_url = (artifact.get("data") or {}).get("download_url")
|
||||
if not download_url:
|
||||
raise RecallAPIError(0, "missing transcript download_url")
|
||||
raw = client.download_json(download_url)
|
||||
text = transcript_download_to_text(raw)
|
||||
if not text.strip():
|
||||
raise RecallAPIError(0, "empty transcript")
|
||||
from pipeline import run_post_transcript_pipeline
|
||||
|
||||
run_post_transcript_pipeline(meeting_id, text)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("Post-transcript pipeline failed")
|
||||
_merge_metadata(meeting_id, {"status": "failed", "error": str(exc)})
|
||||
@@ -0,0 +1,138 @@
|
||||
"""High-level Recall bot scheduling used by API routes and the agent."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from meeting_integrations.recall.client import RecallAPIError, RecallClient
|
||||
from meeting_integrations.recall.config import RecallConfigError, RecallSettings, load_recall_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MEETING_URL_RE = re.compile(r"^https?://", re.I)
|
||||
|
||||
|
||||
def schedule_recall_bot(
|
||||
meeting_url: str,
|
||||
title: str = "",
|
||||
*,
|
||||
join_at: str | None = None,
|
||||
settings: RecallSettings | None = None,
|
||||
client: RecallClient | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Persist local intent, create the Recall bot, and store the returned bot id."""
|
||||
url = (meeting_url or "").strip()
|
||||
if not _MEETING_URL_RE.match(url):
|
||||
return {"error": "meeting_url must be an http(s) URL"}
|
||||
|
||||
meeting_title = (title or "Live meeting").strip() or "Live meeting"
|
||||
|
||||
try:
|
||||
settings = settings or load_recall_settings()
|
||||
except RecallConfigError as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
from praisonai_tools.tools.meeting_tools import save_meeting
|
||||
|
||||
saved = save_meeting.__wrapped__(
|
||||
title=meeting_title,
|
||||
source=url,
|
||||
metadata={
|
||||
"status": "scheduled",
|
||||
"live_status": "scheduled",
|
||||
"meeting_url": url,
|
||||
"source": "recall",
|
||||
},
|
||||
)
|
||||
if "error" in saved:
|
||||
return saved
|
||||
|
||||
meeting_id = saved["meeting_id"]
|
||||
api = client or RecallClient(settings)
|
||||
|
||||
try:
|
||||
bot = api.create_bot(
|
||||
meeting_url=url,
|
||||
meeting_id=meeting_id,
|
||||
title=meeting_title,
|
||||
join_at=join_at,
|
||||
)
|
||||
except RecallAPIError as exc:
|
||||
from pipeline import merge_meeting_metadata
|
||||
|
||||
merge_meeting_metadata(
|
||||
meeting_id,
|
||||
{
|
||||
"status": "failed",
|
||||
"live_status": "failed",
|
||||
"error": f"Recall create bot failed: {exc.detail}",
|
||||
},
|
||||
)
|
||||
return {
|
||||
"meeting_id": meeting_id,
|
||||
"error": f"Recall create bot failed: {exc.detail}",
|
||||
}
|
||||
|
||||
bot_id = bot.get("id")
|
||||
from pipeline import merge_meeting_metadata
|
||||
|
||||
merge_meeting_metadata(
|
||||
meeting_id,
|
||||
{
|
||||
"recall_bot_id": bot_id,
|
||||
"status": "scheduled",
|
||||
"live_status": "scheduled",
|
||||
"meeting_url": url,
|
||||
},
|
||||
)
|
||||
|
||||
realtime = settings.realtime_recording_config() is not None
|
||||
logger.info("Scheduled Recall bot %s for meeting %s", bot_id, meeting_id)
|
||||
return {
|
||||
"meeting_id": meeting_id,
|
||||
"recall_bot_id": bot_id,
|
||||
"status": "scheduled",
|
||||
"live_status": "scheduled",
|
||||
"title": meeting_title,
|
||||
"meeting_url": url,
|
||||
"realtime_transcript_enabled": realtime,
|
||||
"realtime_webhook_url": settings.webhook_url if realtime else None,
|
||||
}
|
||||
|
||||
|
||||
def cancel_recall_bot(
|
||||
meeting_id: str,
|
||||
*,
|
||||
settings: RecallSettings | None = None,
|
||||
client: RecallClient | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Cancel a scheduled or in-call Recall bot."""
|
||||
from praisonai_tools.tools.meeting_tools import get_meeting
|
||||
from pipeline import merge_meeting_metadata
|
||||
|
||||
record = get_meeting.__wrapped__(meeting_id)
|
||||
if "error" in record:
|
||||
return record
|
||||
|
||||
bot_id = (record.get("metadata") or {}).get("recall_bot_id")
|
||||
if not bot_id:
|
||||
return {"error": "No Recall bot linked to this meeting", "meeting_id": meeting_id}
|
||||
|
||||
try:
|
||||
settings = settings or load_recall_settings()
|
||||
except RecallConfigError as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
api = client or RecallClient(settings)
|
||||
try:
|
||||
api.cancel_bot(str(bot_id))
|
||||
except RecallAPIError as exc:
|
||||
return {"meeting_id": meeting_id, "error": str(exc)}
|
||||
|
||||
merge_meeting_metadata(
|
||||
meeting_id,
|
||||
{"live_status": "cancelled", "status": "cancelled"},
|
||||
)
|
||||
return {"meeting_id": meeting_id, "recall_bot_id": bot_id, "status": "cancelled"}
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Persist Recall webhook idempotency and calendar setup state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from meeting_integrations.recall.client import RecallClient
|
||||
|
||||
|
||||
def _state_db_path() -> str:
|
||||
env = os.getenv("PRAISONAI_MEETINGS_DIR")
|
||||
base = Path(env) if env else Path.home() / ".praisonai" / "meetings"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
return str(base / "recall_state.db")
|
||||
|
||||
|
||||
def _connect() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(_state_db_path())
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS recall_webhook_events (
|
||||
event_key TEXT PRIMARY KEY,
|
||||
event_type TEXT NOT NULL,
|
||||
processed_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS recall_calendar_setup (
|
||||
setup_id TEXT PRIMARY KEY,
|
||||
platform TEXT NOT NULL,
|
||||
state_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS recall_scheduled_bots (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
meeting_id TEXT NOT NULL,
|
||||
bot_id TEXT,
|
||||
scheduled_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
return conn
|
||||
|
||||
|
||||
def webhook_event_key(event_type: str, payload: dict[str, Any]) -> str:
|
||||
"""Build a stable idempotency key from webhook metadata."""
|
||||
data = payload.get("data") or {}
|
||||
|
||||
# Streaming transcript events fire many times per bot — never dedupe on bot_id alone.
|
||||
if event_type in ("transcript.data", "transcript.partial_data"):
|
||||
inner = data.get("data") if isinstance(data, dict) else {}
|
||||
bot_id = RecallClient.extract_bot_id(payload) or ""
|
||||
if isinstance(inner, dict):
|
||||
words = inner.get("words")
|
||||
if isinstance(words, list) and words:
|
||||
first = words[0] if isinstance(words[0], dict) else {}
|
||||
last = words[-1] if isinstance(words[-1], dict) else {}
|
||||
word_text = " ".join(
|
||||
w.get("text", "") for w in words if isinstance(w, dict)
|
||||
).strip()
|
||||
return (
|
||||
f"{event_type}:{bot_id}:"
|
||||
f"{first.get('start', first.get('start_time', ''))}:"
|
||||
f"{last.get('end', last.get('end_time', ''))}:"
|
||||
f"{word_text}"
|
||||
)
|
||||
text = str(inner.get("text") or "").strip()
|
||||
start = inner.get("start") or inner.get("start_time") or ""
|
||||
end = inner.get("end") or inner.get("end_time") or ""
|
||||
speaker = inner.get("speaker") or inner.get("speaker_name") or ""
|
||||
return f"{event_type}:{bot_id}:{start}:{end}:{speaker}:{text}"
|
||||
return f"{event_type}:{json.dumps(payload, sort_keys=True)}"
|
||||
|
||||
for part in (
|
||||
payload.get("id"),
|
||||
data.get("id") if isinstance(data, dict) else None,
|
||||
):
|
||||
if part:
|
||||
return f"{event_type}:{part}"
|
||||
bot_id = RecallStore.extract_bot_id(payload)
|
||||
recording_id = RecallStore.extract_recording_id(payload)
|
||||
transcript_id = RecallStore.extract_transcript_id(payload)
|
||||
suffix = bot_id or recording_id or transcript_id or json.dumps(payload, sort_keys=True)
|
||||
return f"{event_type}:{suffix}"
|
||||
|
||||
|
||||
class RecallStore:
|
||||
"""SQLite-backed Recall integration state."""
|
||||
|
||||
@staticmethod
|
||||
def extract_bot_id(payload: dict[str, Any]) -> str | None:
|
||||
from meeting_integrations.recall.client import RecallClient
|
||||
|
||||
return RecallClient.extract_bot_id(payload)
|
||||
|
||||
@staticmethod
|
||||
def extract_recording_id(payload: dict[str, Any]) -> str | None:
|
||||
from meeting_integrations.recall.client import RecallClient
|
||||
|
||||
return RecallClient.extract_recording_id(payload)
|
||||
|
||||
@staticmethod
|
||||
def extract_transcript_id(payload: dict[str, Any]) -> str | None:
|
||||
from meeting_integrations.recall.client import RecallClient
|
||||
|
||||
return RecallClient.extract_transcript_id(payload)
|
||||
|
||||
@staticmethod
|
||||
def mark_event_processed(event_key: str, event_type: str) -> bool:
|
||||
"""Claim an event for processing; return False if already processed.
|
||||
|
||||
Uses an atomic ``INSERT OR IGNORE`` so concurrent duplicate webhook
|
||||
deliveries (Svix retries/fan-out) cannot both pass the check — the
|
||||
second insert is ignored and reports zero changed rows.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
with closing(_connect()) as conn, conn:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO recall_webhook_events
|
||||
(event_key, event_type, processed_at)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(event_key, event_type, datetime.now(timezone.utc).isoformat()),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
def save_calendar_setup(setup_id: str, platform: str, state: dict[str, Any]) -> None:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
with closing(_connect()) as conn, conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO recall_calendar_setup (setup_id, platform, state_json, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(setup_id) DO UPDATE SET
|
||||
platform = excluded.platform,
|
||||
state_json = excluded.state_json,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
setup_id,
|
||||
platform,
|
||||
json.dumps(state, sort_keys=True),
|
||||
datetime.now(timezone.utc).isoformat(),
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_calendar_setup(setup_id: str) -> dict[str, Any] | None:
|
||||
with closing(_connect()) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT state_json FROM recall_calendar_setup WHERE setup_id = ?",
|
||||
(setup_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return json.loads(row[0])
|
||||
|
||||
@staticmethod
|
||||
def record_scheduled_bot(event_id: str, meeting_id: str, bot_id: str | None) -> None:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
with closing(_connect()) as conn, conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO recall_scheduled_bots (event_id, meeting_id, bot_id, scheduled_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(event_id) DO NOTHING
|
||||
""",
|
||||
(event_id, meeting_id, bot_id, datetime.now(timezone.utc).isoformat()),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def is_event_scheduled(event_id: str) -> bool:
|
||||
with closing(_connect()) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM recall_scheduled_bots WHERE event_id = ?",
|
||||
(event_id,),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
@staticmethod
|
||||
def get_scheduled_bot(event_id: str) -> dict[str, Any] | None:
|
||||
with closing(_connect()) as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT event_id, meeting_id, bot_id, scheduled_at
|
||||
FROM recall_scheduled_bots WHERE event_id = ?
|
||||
""",
|
||||
(event_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"event_id": row[0],
|
||||
"meeting_id": row[1],
|
||||
"bot_id": row[2],
|
||||
"scheduled_at": row[3],
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Convert Recall transcript download payloads to plain text."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def transcript_data_to_line(payload: dict[str, Any]) -> str:
|
||||
"""Flatten a verified ``transcript.data`` webhook payload to one line."""
|
||||
data = payload.get("data") or {}
|
||||
if not isinstance(data, dict):
|
||||
return ""
|
||||
|
||||
inner = data.get("data") or data
|
||||
if not isinstance(inner, dict):
|
||||
return ""
|
||||
|
||||
words = inner.get("words")
|
||||
if isinstance(words, list) and words:
|
||||
text = " ".join(
|
||||
w.get("text", "") for w in words if isinstance(w, dict)
|
||||
).strip()
|
||||
else:
|
||||
text = str(inner.get("text") or "").strip()
|
||||
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
speaker = inner.get("speaker") or inner.get("speaker_name")
|
||||
participant = inner.get("participant")
|
||||
if not speaker and isinstance(participant, dict):
|
||||
speaker = participant.get("name")
|
||||
return f"{speaker}: {text}" if speaker else text
|
||||
|
||||
|
||||
def transcript_download_to_text(data: Any) -> str:
|
||||
"""Flatten Recall transcript JSON into a readable multi-line string."""
|
||||
if isinstance(data, str):
|
||||
return data.strip()
|
||||
if not isinstance(data, list):
|
||||
return str(data)
|
||||
|
||||
lines: list[str] = []
|
||||
for item in data:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
speaker = item.get("speaker") or item.get("speaker_name")
|
||||
participant = item.get("participant")
|
||||
if not speaker and isinstance(participant, dict):
|
||||
speaker = participant.get("name")
|
||||
words = item.get("words")
|
||||
if isinstance(words, list) and words:
|
||||
text = " ".join(
|
||||
w.get("text", "") for w in words if isinstance(w, dict)
|
||||
).strip()
|
||||
else:
|
||||
text = str(item.get("text") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
prefix = f"{speaker}: " if speaker else ""
|
||||
lines.append(f"{prefix}{text}")
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Verify incoming Recall webhook and callback requests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hmac
|
||||
import hashlib
|
||||
|
||||
|
||||
class VerificationError(ValueError):
|
||||
"""Signature verification failed."""
|
||||
|
||||
|
||||
def verify_request_from_recall(
|
||||
*,
|
||||
secret: str,
|
||||
headers: dict[str, str],
|
||||
payload: str | None,
|
||||
) -> None:
|
||||
"""Verify Recall/Svix-style webhook signatures on the raw request body."""
|
||||
if not secret or not secret.startswith("whsec_"):
|
||||
raise VerificationError("Verification secret is missing or invalid")
|
||||
|
||||
lowered = {k.lower(): v for k, v in headers.items()}
|
||||
msg_id = lowered.get("webhook-id") or lowered.get("svix-id")
|
||||
msg_timestamp = lowered.get("webhook-timestamp") or lowered.get("svix-timestamp")
|
||||
msg_signature = lowered.get("webhook-signature") or lowered.get("svix-signature")
|
||||
|
||||
if not msg_id or not msg_timestamp or not msg_signature:
|
||||
raise VerificationError("Missing webhook id, timestamp, or signature")
|
||||
|
||||
key = base64.b64decode(secret.removeprefix("whsec_"))
|
||||
payload_str = payload or ""
|
||||
to_sign = f"{msg_id}.{msg_timestamp}.{payload_str}"
|
||||
expected_sig = base64.b64encode(
|
||||
hmac.new(key, to_sign.encode("utf-8"), hashlib.sha256).digest()
|
||||
).decode("ascii")
|
||||
|
||||
for versioned_sig in msg_signature.split(" "):
|
||||
if "," not in versioned_sig:
|
||||
continue
|
||||
version, signature = versioned_sig.split(",", 1)
|
||||
if version != "v1":
|
||||
continue
|
||||
if hmac.compare_digest(signature, expected_sig):
|
||||
return
|
||||
|
||||
raise VerificationError("No matching signature found")
|
||||
@@ -0,0 +1,295 @@
|
||||
"""Meeting ingest pipeline — upload → transcribe → summarize → index.
|
||||
|
||||
Uses PraisonAI-Tools meeting tools with lazy imports so the UI app stays
|
||||
lightweight at import time. Updates meeting metadata in SQLite between steps.
|
||||
Status lives in meeting metadata (tools layer), not in UI state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Serializes metadata read-modify-write. Webhooks are dispatched one-thread-each
|
||||
# (asyncio.to_thread), so concurrent transcript events would otherwise clobber
|
||||
# each other's appends. Reentrant so callers can hold it across their own
|
||||
# read + merge (see processor._upsert_live_transcript).
|
||||
_metadata_lock = threading.RLock()
|
||||
|
||||
AUDIO_SUFFIXES = {".mp3", ".mp4", ".m4a", ".wav", ".webm", ".ogg", ".mpeg", ".mpga"}
|
||||
_MEETING_ID_RE = re.compile(
|
||||
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def merge_meeting_metadata(meeting_id: str, patch: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Merge keys into a meeting's metadata blob."""
|
||||
from praisonai_tools.tools import meeting_tools as mt
|
||||
|
||||
with _metadata_lock:
|
||||
record = mt.get_meeting.__wrapped__(meeting_id)
|
||||
if "error" in record:
|
||||
return record
|
||||
meta = {**(record.get("metadata") or {}), **patch}
|
||||
try:
|
||||
with closing(mt._connect()) as conn, conn:
|
||||
conn.execute(
|
||||
"UPDATE meetings SET metadata = ? WHERE meeting_id = ?",
|
||||
(json.dumps(meta, sort_keys=True), meeting_id),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("merge_meeting_metadata failed: %s", exc)
|
||||
return {"error": str(exc)}
|
||||
return {"meeting_id": meeting_id, "metadata": meta}
|
||||
|
||||
|
||||
def _pipeline_from_transcribe(
|
||||
meeting_id: str,
|
||||
file_path: str,
|
||||
meeting_title: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Shared tail: transcribe → summarize → index for a known meeting_id."""
|
||||
from praisonai_tools.tools.meeting_tools import (
|
||||
extract_action_items,
|
||||
index_meeting,
|
||||
summarize_transcript,
|
||||
transcribe_file,
|
||||
)
|
||||
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
merge_meeting_metadata(
|
||||
meeting_id,
|
||||
{"status": "failed", "error": f"File not found: {file_path}"},
|
||||
)
|
||||
return {"meeting_id": meeting_id, "error": f"File not found: {file_path}"}
|
||||
|
||||
merge_meeting_metadata(
|
||||
meeting_id,
|
||||
{"status": "transcribing", "file_path": str(path), "error": None},
|
||||
)
|
||||
|
||||
transcript_result = transcribe_file.__wrapped__(str(path))
|
||||
if "error" in transcript_result:
|
||||
merge_meeting_metadata(
|
||||
meeting_id,
|
||||
{"status": "failed", "error": transcript_result["error"]},
|
||||
)
|
||||
return {"meeting_id": meeting_id, **transcript_result}
|
||||
|
||||
transcript = transcript_result.get("text", "")
|
||||
segments = transcript_result.get("segments") or []
|
||||
merge_meeting_metadata(
|
||||
meeting_id,
|
||||
{
|
||||
"status": "summarizing",
|
||||
"transcript": transcript,
|
||||
"segments": segments,
|
||||
"duration_seconds": transcript_result.get("duration_seconds"),
|
||||
"language": transcript_result.get("language"),
|
||||
},
|
||||
)
|
||||
|
||||
summary_result = summarize_transcript.__wrapped__(transcript)
|
||||
if "error" in summary_result:
|
||||
merge_meeting_metadata(
|
||||
meeting_id,
|
||||
{"status": "failed", "error": summary_result["error"]},
|
||||
)
|
||||
return {"meeting_id": meeting_id, **summary_result}
|
||||
|
||||
actions_result = extract_action_items.__wrapped__(transcript)
|
||||
if "error" in actions_result:
|
||||
merge_meeting_metadata(
|
||||
meeting_id,
|
||||
{"status": "failed", "error": actions_result["error"]},
|
||||
)
|
||||
return {"meeting_id": meeting_id, **actions_result}
|
||||
|
||||
merge_meeting_metadata(
|
||||
meeting_id,
|
||||
{
|
||||
"status": "indexing",
|
||||
"summary": summary_result.get("summary", ""),
|
||||
"decisions": summary_result.get("decisions", []),
|
||||
"topics": summary_result.get("topics", []),
|
||||
"key_quotes": summary_result.get("key_quotes", []),
|
||||
"action_items": actions_result.get("action_items", []),
|
||||
},
|
||||
)
|
||||
|
||||
index_result = index_meeting.__wrapped__(
|
||||
meeting_id,
|
||||
transcript=transcript,
|
||||
segments=segments,
|
||||
)
|
||||
if "error" in index_result:
|
||||
merge_meeting_metadata(
|
||||
meeting_id,
|
||||
{"status": "failed", "error": index_result["error"]},
|
||||
)
|
||||
return {"meeting_id": meeting_id, **index_result}
|
||||
|
||||
merge_meeting_metadata(meeting_id, {"status": "ready", "error": None})
|
||||
|
||||
return {
|
||||
"meeting_id": meeting_id,
|
||||
"status": "ready",
|
||||
"title": meeting_title,
|
||||
"summary": summary_result.get("summary", ""),
|
||||
"action_items": actions_result.get("action_items", []),
|
||||
"chunks_indexed": index_result.get("chunks_indexed", 0),
|
||||
}
|
||||
|
||||
|
||||
def run_ingest_pipeline(
|
||||
file_path: str,
|
||||
title: str | None = None,
|
||||
*,
|
||||
meeting_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run the full Phase 1 pipeline for an audio/video upload."""
|
||||
from praisonai_tools.tools.meeting_tools import get_meeting, save_meeting
|
||||
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
return {"error": f"File not found: {file_path}"}
|
||||
|
||||
meeting_title = (title or path.stem).strip() or "Untitled meeting"
|
||||
|
||||
if meeting_id:
|
||||
record = get_meeting.__wrapped__(meeting_id)
|
||||
if "error" in record:
|
||||
return record
|
||||
if title:
|
||||
meeting_title = title
|
||||
elif record.get("title"):
|
||||
meeting_title = record["title"]
|
||||
else:
|
||||
saved = save_meeting.__wrapped__(
|
||||
title=meeting_title,
|
||||
source=str(path),
|
||||
metadata={"status": "transcribing", "file_path": str(path)},
|
||||
)
|
||||
if "error" in saved:
|
||||
return saved
|
||||
meeting_id = saved["meeting_id"]
|
||||
|
||||
return _pipeline_from_transcribe(meeting_id, str(path), meeting_title)
|
||||
|
||||
|
||||
def run_post_transcript_pipeline(
|
||||
meeting_id: str,
|
||||
transcript: str,
|
||||
*,
|
||||
segments: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run summarize → index for a Recall (or other) transcript without transcribe step."""
|
||||
from praisonai_tools.tools.meeting_tools import (
|
||||
extract_action_items,
|
||||
get_meeting,
|
||||
index_meeting,
|
||||
summarize_transcript,
|
||||
)
|
||||
|
||||
record = get_meeting.__wrapped__(meeting_id)
|
||||
if "error" in record:
|
||||
return record
|
||||
|
||||
meeting_title = record.get("title") or "Untitled meeting"
|
||||
merge_meeting_metadata(
|
||||
meeting_id,
|
||||
{
|
||||
"status": "summarizing",
|
||||
"transcript": transcript,
|
||||
"segments": segments or [],
|
||||
"source": "recall",
|
||||
"live_status": "ready",
|
||||
"error": None,
|
||||
},
|
||||
)
|
||||
|
||||
summary_result = summarize_transcript.__wrapped__(transcript)
|
||||
if "error" in summary_result:
|
||||
merge_meeting_metadata(
|
||||
meeting_id,
|
||||
{"status": "failed", "error": summary_result["error"]},
|
||||
)
|
||||
return {"meeting_id": meeting_id, **summary_result}
|
||||
|
||||
actions_result = extract_action_items.__wrapped__(transcript)
|
||||
if "error" in actions_result:
|
||||
merge_meeting_metadata(
|
||||
meeting_id,
|
||||
{"status": "failed", "error": actions_result["error"]},
|
||||
)
|
||||
return {"meeting_id": meeting_id, **actions_result}
|
||||
|
||||
merge_meeting_metadata(
|
||||
meeting_id,
|
||||
{
|
||||
"status": "indexing",
|
||||
"summary": summary_result.get("summary", ""),
|
||||
"decisions": summary_result.get("decisions", []),
|
||||
"topics": summary_result.get("topics", []),
|
||||
"key_quotes": summary_result.get("key_quotes", []),
|
||||
"action_items": actions_result.get("action_items", []),
|
||||
},
|
||||
)
|
||||
|
||||
index_result = index_meeting.__wrapped__(
|
||||
meeting_id,
|
||||
transcript=transcript,
|
||||
segments=segments or [],
|
||||
)
|
||||
if "error" in index_result:
|
||||
merge_meeting_metadata(
|
||||
meeting_id,
|
||||
{"status": "failed", "error": index_result["error"]},
|
||||
)
|
||||
return {"meeting_id": meeting_id, **index_result}
|
||||
|
||||
merge_meeting_metadata(meeting_id, {"status": "ready", "error": None})
|
||||
|
||||
return {
|
||||
"meeting_id": meeting_id,
|
||||
"status": "ready",
|
||||
"title": meeting_title,
|
||||
"summary": summary_result.get("summary", ""),
|
||||
"action_items": actions_result.get("action_items", []),
|
||||
"chunks_indexed": index_result.get("chunks_indexed", 0),
|
||||
}
|
||||
|
||||
|
||||
def retry_pipeline(meeting_id: str) -> dict[str, Any]:
|
||||
"""Re-run ingest for a failed meeting using its stored file path."""
|
||||
if not meeting_id or not _MEETING_ID_RE.match(meeting_id):
|
||||
return {"error": "invalid meeting_id", "meeting_id": meeting_id}
|
||||
|
||||
from praisonai_tools.tools.meeting_tools import get_meeting
|
||||
|
||||
record = get_meeting.__wrapped__(meeting_id)
|
||||
if "error" in record:
|
||||
return record
|
||||
|
||||
meta = record.get("metadata") or {}
|
||||
file_path = meta.get("file_path") or record.get("source")
|
||||
if not file_path:
|
||||
return {
|
||||
"meeting_id": meeting_id,
|
||||
"error": "No uploaded file stored for this meeting",
|
||||
}
|
||||
|
||||
return run_ingest_pipeline(
|
||||
str(file_path),
|
||||
title=record.get("title"),
|
||||
meeting_id=meeting_id,
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Meeting Agent dashboard hooks — auto-refresh live transcript on meeting-detail.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
let pollTimer = null;
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshLiveTranscript() {
|
||||
const host = document.querySelector('[data-page="meeting-detail"]');
|
||||
if (!host) return;
|
||||
|
||||
const code = host.querySelector('.db-code-block code');
|
||||
if (!code) return;
|
||||
|
||||
try {
|
||||
const meetingsRes = await fetch('/api/meetings');
|
||||
if (!meetingsRes.ok) return;
|
||||
const meetingsJson = await meetingsRes.json();
|
||||
const meetings = meetingsJson.meetings || [];
|
||||
const live = meetings.find((m) =>
|
||||
['live', 'joining', 'waiting_room'].includes(String(m.live_status || '').toLowerCase())
|
||||
);
|
||||
if (!live || !live.id) return;
|
||||
|
||||
const snapRes = await fetch(`/api/meetings/${live.id}/live-transcript`);
|
||||
if (!snapRes.ok) return;
|
||||
const snap = await snapRes.json();
|
||||
const text = snap.transcript || '(empty)';
|
||||
if (code.textContent !== text) {
|
||||
code.textContent = text;
|
||||
}
|
||||
} catch (_err) {
|
||||
/* ignore transient network errors */
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
refreshLiveTranscript();
|
||||
pollTimer = setInterval(refreshLiveTranscript, 3000);
|
||||
}
|
||||
|
||||
window.addEventListener('aiui:page-change', (event) => {
|
||||
stopPolling();
|
||||
if (event.detail && event.detail.pageId === 'meeting-detail') {
|
||||
startPolling();
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,61 @@
|
||||
# Poll live transcript for a meeting (Part B dev test).
|
||||
# Usage:
|
||||
# .\poll_live_transcript.ps1
|
||||
# .\poll_live_transcript.ps1 -MeetingId "aecf34bc-7194-4123-9c67-a298b56011da"
|
||||
# .\poll_live_transcript.ps1 -IntervalSec 2
|
||||
|
||||
param(
|
||||
[string]$MeetingId = "",
|
||||
[int]$Port = 8000,
|
||||
[int]$IntervalSec = 3
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$base = "http://127.0.0.1:$Port"
|
||||
|
||||
if (-not $MeetingId) {
|
||||
$meetings = Invoke-RestMethod -Uri "$base/api/meetings"
|
||||
$live = $meetings.meetings | Where-Object { @("live","joining","waiting_room") -contains ($_.live_status).ToLower() } | Select-Object -First 1
|
||||
if ($live) {
|
||||
$MeetingId = $live.id
|
||||
} elseif ($meetings.meetings.Count -gt 0) {
|
||||
$MeetingId = $meetings.meetings[0].id
|
||||
} else {
|
||||
throw "No meetings found. Schedule a bot first."
|
||||
}
|
||||
}
|
||||
|
||||
$url = "$base/api/meetings/$MeetingId/live-transcript"
|
||||
|
||||
Write-Host "Polling $url every ${IntervalSec}s (Ctrl+C to stop)"
|
||||
Write-Host ""
|
||||
|
||||
$lastCount = -1
|
||||
$lastText = ""
|
||||
|
||||
while ($true) {
|
||||
try {
|
||||
$r = Invoke-RestMethod -Uri $url -TimeoutSec 10
|
||||
$count = [int]$r.line_count
|
||||
$status = $r.live_status
|
||||
$text = [string]$r.transcript
|
||||
|
||||
if ($count -ne $lastCount -or $text -ne $lastText) {
|
||||
$ts = Get-Date -Format "HH:mm:ss"
|
||||
Write-Host "[$ts] live_status=$status lines=$count"
|
||||
if ($text) {
|
||||
Write-Host "---"
|
||||
Write-Host $text
|
||||
Write-Host "---"
|
||||
} else {
|
||||
Write-Host "(empty transcript)"
|
||||
}
|
||||
Write-Host ""
|
||||
$lastCount = $count
|
||||
$lastText = $text
|
||||
}
|
||||
} catch {
|
||||
Write-Host "[$(Get-Date -Format 'HH:mm:ss')] ERROR: $($_.Exception.Message)"
|
||||
}
|
||||
Start-Sleep -Seconds $IntervalSec
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
"""Recall.ai HTTP routes for the meeting-agent example."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, PlainTextResponse, Response, StreamingResponse
|
||||
|
||||
from meeting_integrations.recall.calendar import forward_calendar_callback
|
||||
from meeting_integrations.recall.calendar_service import (
|
||||
list_upcoming_calendar_meetings,
|
||||
process_calendar_webhook,
|
||||
sync_calendar_events,
|
||||
)
|
||||
from meeting_integrations.recall.config import RecallConfigError, load_recall_settings
|
||||
from meeting_integrations.recall.live import snapshot_for_api, wait_for_update
|
||||
from meeting_integrations.recall.processor import process_recall_webhook
|
||||
from meeting_integrations.recall.service import cancel_recall_bot, schedule_recall_bot
|
||||
from meeting_integrations.recall.store import RecallStore
|
||||
from meeting_integrations.recall.verify import VerificationError, verify_request_from_recall
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_calendar_regional_uri: str | None = None
|
||||
|
||||
# asyncio keeps only weak references to tasks, so a bare create_task() can be
|
||||
# garbage-collected mid-flight and silently drop a webhook event. Hold a strong
|
||||
# reference until each background task finishes.
|
||||
_background_tasks: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
def _dispatch_background(coro) -> None:
|
||||
task = asyncio.create_task(coro)
|
||||
_background_tasks.add(task)
|
||||
task.add_done_callback(_background_tasks.discard)
|
||||
|
||||
|
||||
def set_calendar_regional_callback_uri(uri: str | None) -> None:
|
||||
global _calendar_regional_uri
|
||||
_calendar_regional_uri = uri
|
||||
|
||||
|
||||
async def api_recall_schedule_bot(request: Request) -> JSONResponse:
|
||||
try:
|
||||
body = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
return JSONResponse({"error": "invalid JSON body"}, status_code=400)
|
||||
|
||||
meeting_url = str(body.get("meeting_url") or "").strip()
|
||||
title = str(body.get("title") or "").strip()
|
||||
join_at = body.get("join_at")
|
||||
join_at_str = str(join_at).strip() if join_at else None
|
||||
|
||||
result = await asyncio.to_thread(
|
||||
schedule_recall_bot,
|
||||
meeting_url,
|
||||
title,
|
||||
join_at=join_at_str,
|
||||
)
|
||||
status = 201 if result.get("recall_bot_id") else 400 if "error" in result else 500
|
||||
return JSONResponse(result, status_code=status)
|
||||
|
||||
|
||||
async def api_recall_cancel_bot(request: Request) -> JSONResponse:
|
||||
meeting_id = request.path_params["meeting_id"]
|
||||
result = await asyncio.to_thread(cancel_recall_bot, meeting_id)
|
||||
status = 200 if "error" not in result else 400
|
||||
return JSONResponse(result, status_code=status)
|
||||
|
||||
|
||||
async def api_recall_bot_status(request: Request) -> JSONResponse:
|
||||
meeting_id = request.path_params["meeting_id"]
|
||||
try:
|
||||
from praisonai_tools.tools.meeting_tools import get_meeting
|
||||
|
||||
record = get_meeting.__wrapped__(meeting_id)
|
||||
except ImportError:
|
||||
return JSONResponse({"error": "meeting tools unavailable"}, status_code=503)
|
||||
if "error" in record:
|
||||
return JSONResponse(record, status_code=404)
|
||||
meta = record.get("metadata") or {}
|
||||
return JSONResponse(
|
||||
{
|
||||
"meeting_id": meeting_id,
|
||||
"title": record.get("title"),
|
||||
"status": meta.get("status"),
|
||||
"live_status": meta.get("live_status"),
|
||||
"recall_bot_id": meta.get("recall_bot_id"),
|
||||
"meeting_url": meta.get("meeting_url"),
|
||||
"transcript_preview": (meta.get("transcript") or "")[:500],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def webhook_recall(request: Request) -> Response:
|
||||
raw = (await request.body()).decode("utf-8")
|
||||
try:
|
||||
settings = load_recall_settings()
|
||||
except RecallConfigError as exc:
|
||||
return JSONResponse({"error": str(exc)}, status_code=503)
|
||||
|
||||
headers = {k: v for k, v in request.headers.items()}
|
||||
try:
|
||||
verify_request_from_recall(
|
||||
secret=settings.webhook_verification_secret,
|
||||
headers=headers,
|
||||
payload=raw,
|
||||
)
|
||||
except VerificationError:
|
||||
return JSONResponse({"error": "invalid signature"}, status_code=401)
|
||||
|
||||
try:
|
||||
payload = json.loads(raw) if raw else {}
|
||||
except json.JSONDecodeError:
|
||||
return JSONResponse({"error": "invalid JSON"}, status_code=400)
|
||||
|
||||
event_type = str(payload.get("event") or payload.get("type") or "unknown")
|
||||
if event_type.startswith("calendar."):
|
||||
_dispatch_background(
|
||||
asyncio.to_thread(
|
||||
process_calendar_webhook,
|
||||
event_type,
|
||||
payload,
|
||||
settings=settings,
|
||||
)
|
||||
)
|
||||
return PlainTextResponse("ok", status_code=200)
|
||||
|
||||
_dispatch_background(
|
||||
asyncio.to_thread(process_recall_webhook, event_type, payload, settings=settings)
|
||||
)
|
||||
return PlainTextResponse("ok", status_code=200)
|
||||
|
||||
|
||||
async def api_recall_calendar_callback(request: Request) -> Response:
|
||||
if not _calendar_regional_uri:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Calendar callback not configured. Bootstrap setup via "
|
||||
"POST /api/recall/calendar/setup/bootstrap with regional_callback_uri "
|
||||
"from Recall MCP start_calendar_integration_setup."
|
||||
},
|
||||
status_code=503,
|
||||
)
|
||||
params = dict(request.query_params)
|
||||
status, body = await asyncio.to_thread(
|
||||
forward_calendar_callback,
|
||||
regional_callback_uri=_calendar_regional_uri,
|
||||
query_params=params,
|
||||
)
|
||||
return PlainTextResponse(body, status_code=status)
|
||||
|
||||
|
||||
async def api_recall_calendar_bootstrap(request: Request) -> JSONResponse:
|
||||
try:
|
||||
body = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
return JSONResponse({"error": "invalid JSON"}, status_code=400)
|
||||
|
||||
setup_id = str(body.get("setup_id") or "").strip()
|
||||
platform = str(body.get("platform") or "google_calendar").strip()
|
||||
regional_uri = str(body.get("regional_callback_uri") or "").strip()
|
||||
state = body.get("state") if isinstance(body.get("state"), dict) else {}
|
||||
|
||||
if not setup_id or not regional_uri:
|
||||
return JSONResponse(
|
||||
{"error": "setup_id and regional_callback_uri are required"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
set_calendar_regional_callback_uri(regional_uri)
|
||||
RecallStore.save_calendar_setup(
|
||||
setup_id,
|
||||
platform,
|
||||
{
|
||||
**state,
|
||||
"regional_callback_uri": regional_uri,
|
||||
"platform": platform,
|
||||
},
|
||||
)
|
||||
callback_url = "(set PUBLIC_API_BASE_URL)"
|
||||
if os.getenv("PUBLIC_API_BASE_URL", "").strip():
|
||||
try:
|
||||
callback_url = load_recall_settings().calendar_callback_url
|
||||
except RecallConfigError:
|
||||
pass
|
||||
return JSONResponse(
|
||||
{
|
||||
"setup_id": setup_id,
|
||||
"callback_url": callback_url,
|
||||
"status": "bootstrapped",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def api_recall_calendar_status(_request: Request) -> JSONResponse:
|
||||
auto_record = os.getenv("CALENDAR_AUTO_RECORD", "true").strip().lower() not in {
|
||||
"0",
|
||||
"false",
|
||||
"no",
|
||||
"off",
|
||||
}
|
||||
public_url = os.getenv("PUBLIC_API_BASE_URL", "").strip()
|
||||
calendars: list[dict[str, Any]] = []
|
||||
try:
|
||||
from meeting_integrations.recall.client import RecallClient
|
||||
|
||||
settings = load_recall_settings()
|
||||
calendars = RecallClient(settings).list_calendars(status="connected")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("Could not list calendars: %s", exc)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"calendar_v2": {
|
||||
"auto_record_eligible_events": auto_record,
|
||||
"connected_calendars": len(calendars),
|
||||
"calendars": [
|
||||
{
|
||||
"id": c.get("id"),
|
||||
"platform": c.get("platform"),
|
||||
"email": c.get("platform_email") or c.get("email"),
|
||||
"status": c.get("status"),
|
||||
}
|
||||
for c in calendars
|
||||
if isinstance(c, dict)
|
||||
],
|
||||
"callback_configured": bool(_calendar_regional_uri),
|
||||
"public_api_base_url_set": bool(public_url),
|
||||
"webhook_events": ["calendar.sync_events", "calendar.update"],
|
||||
"next_step": (
|
||||
"Register calendar webhooks on the same /webhooks/recall URL, "
|
||||
"then complete Google Calendar V2 OAuth via Recall MCP."
|
||||
),
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def api_recall_calendar_upcoming(request: Request) -> JSONResponse:
|
||||
hours = int(request.query_params.get("hours", "24"))
|
||||
try:
|
||||
rows = await asyncio.to_thread(list_upcoming_calendar_meetings, hours=hours)
|
||||
except RecallConfigError as exc:
|
||||
return JSONResponse({"error": str(exc)}, status_code=503)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return JSONResponse({"error": str(exc)}, status_code=502)
|
||||
return JSONResponse({"events": rows, "hours": hours})
|
||||
|
||||
|
||||
async def api_recall_calendar_sync(request: Request) -> JSONResponse:
|
||||
try:
|
||||
body = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
body = {}
|
||||
calendar_id = str(body.get("calendar_id") or request.query_params.get("calendar_id") or "").strip()
|
||||
if not calendar_id:
|
||||
return JSONResponse({"error": "calendar_id is required"}, status_code=400)
|
||||
try:
|
||||
settings = load_recall_settings()
|
||||
result = await asyncio.to_thread(
|
||||
sync_calendar_events,
|
||||
calendar_id,
|
||||
updated_since=body.get("updated_at__gte"),
|
||||
settings=settings,
|
||||
)
|
||||
except RecallConfigError as exc:
|
||||
return JSONResponse({"error": str(exc)}, status_code=503)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return JSONResponse({"error": str(exc)}, status_code=502)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
async def api_meeting_live_transcript(request: Request) -> Response:
|
||||
meeting_id = request.path_params["meeting_id"]
|
||||
accept = request.headers.get("accept", "")
|
||||
try:
|
||||
from praisonai_tools.tools.meeting_tools import get_meeting
|
||||
|
||||
record = get_meeting.__wrapped__(meeting_id)
|
||||
except ImportError:
|
||||
return JSONResponse({"error": "meeting tools unavailable"}, status_code=503)
|
||||
if "error" in record:
|
||||
return JSONResponse(record, status_code=404)
|
||||
|
||||
if "text/event-stream" not in accept:
|
||||
return JSONResponse(snapshot_for_api(meeting_id, record))
|
||||
|
||||
async def event_stream():
|
||||
last_text = ""
|
||||
while True:
|
||||
try:
|
||||
from praisonai_tools.tools.meeting_tools import get_meeting
|
||||
|
||||
record = get_meeting.__wrapped__(meeting_id)
|
||||
except ImportError:
|
||||
break
|
||||
snap = snapshot_for_api(meeting_id, record)
|
||||
text = snap.get("transcript") or ""
|
||||
live = (snap.get("live_status") or "").lower()
|
||||
if text != last_text:
|
||||
yield f"data: {json.dumps(snap)}\n\n"
|
||||
last_text = text
|
||||
if live not in ("live", "joining", "waiting_room", "scheduled", ""):
|
||||
break
|
||||
await asyncio.to_thread(wait_for_update, meeting_id, 25.0)
|
||||
|
||||
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
||||
|
||||
|
||||
async def api_recall_config_status(_request: Request) -> JSONResponse:
|
||||
try:
|
||||
settings = load_recall_settings()
|
||||
configured = True
|
||||
except RecallConfigError as exc:
|
||||
settings = None
|
||||
configured = False
|
||||
error = str(exc)
|
||||
else:
|
||||
error = None
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"configured": configured,
|
||||
"error": error,
|
||||
"region": settings.region if settings else os.getenv("RECALL_REGION"),
|
||||
"workspace_id": settings.workspace_id if settings else None,
|
||||
"webhook_url": settings.webhook_url if settings and settings.public_api_base_url else None,
|
||||
"bot_name": settings.bot_name if settings else None,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Recover a Recall meeting when webhooks missed (stale tunnel URL).
|
||||
|
||||
Usage:
|
||||
py -3.13 recover_recall_meeting.py <meeting_id> [bot_id]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
_EXAMPLE_DIR = Path(__file__).resolve().parent
|
||||
if str(_EXAMPLE_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLE_DIR))
|
||||
|
||||
|
||||
def _load_env() -> None:
|
||||
import os
|
||||
|
||||
env_path = _EXAMPLE_DIR / ".env"
|
||||
if not env_path.is_file():
|
||||
return
|
||||
for raw in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key, value = key.strip(), value.strip()
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def _duration_seconds(started_at: str | None, completed_at: str | None) -> int | None:
|
||||
if not started_at or not completed_at:
|
||||
return None
|
||||
try:
|
||||
fmt = "%Y-%m-%dT%H:%M:%S.%f%z"
|
||||
start = datetime.strptime(started_at.replace("Z", "+0000"), fmt)
|
||||
end = datetime.strptime(completed_at.replace("Z", "+0000"), fmt)
|
||||
return int((end - start).total_seconds())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def recover(meeting_id: str, bot_id: str | None = None) -> dict:
|
||||
from meeting_integrations.recall.client import RecallClient
|
||||
from meeting_integrations.recall.config import load_recall_settings
|
||||
from meeting_integrations.recall.transcript import transcript_download_to_text
|
||||
from pipeline import merge_meeting_metadata, run_post_transcript_pipeline
|
||||
from praisonai_tools.tools.meeting_tools import get_meeting
|
||||
|
||||
_load_env()
|
||||
settings = load_recall_settings()
|
||||
client = RecallClient(settings)
|
||||
|
||||
record = get_meeting.__wrapped__(meeting_id)
|
||||
if "error" in record:
|
||||
raise SystemExit(record["error"])
|
||||
|
||||
meta = record.get("metadata") or {}
|
||||
bot_id = bot_id or meta.get("recall_bot_id")
|
||||
if not bot_id:
|
||||
raise SystemExit("No recall_bot_id — pass bot_id as second argument")
|
||||
|
||||
bot = client.get_bot(str(bot_id))
|
||||
recordings = bot.get("recordings") or []
|
||||
if not recordings:
|
||||
raise SystemExit("Bot has no recordings yet")
|
||||
|
||||
rec_wrap = recordings[0].get("recording") or recordings[0]
|
||||
recording_id = rec_wrap.get("id")
|
||||
if not recording_id:
|
||||
raise SystemExit("Missing recording id on bot")
|
||||
|
||||
duration = _duration_seconds(rec_wrap.get("started_at"), rec_wrap.get("completed_at"))
|
||||
merge_meeting_metadata(
|
||||
meeting_id,
|
||||
{
|
||||
"recall_bot_id": bot_id,
|
||||
"recall_recording_id": recording_id,
|
||||
"live_status": "processing",
|
||||
"status": "transcribing",
|
||||
**({"duration_seconds": duration} if duration else {}),
|
||||
},
|
||||
)
|
||||
|
||||
transcript_id = None
|
||||
for _ in range(30):
|
||||
bot = client.get_bot(str(bot_id))
|
||||
recs = bot.get("recordings") or []
|
||||
if recs:
|
||||
rec = recs[0].get("recording") or recs[0]
|
||||
shortcuts = rec.get("media_shortcuts") or {}
|
||||
tr = shortcuts.get("transcript")
|
||||
if isinstance(tr, dict) and tr.get("id"):
|
||||
transcript_id = str(tr["id"])
|
||||
break
|
||||
artifacts = recs[0].get("transcript_artifacts") or rec.get("transcript_artifacts") or []
|
||||
if artifacts:
|
||||
transcript_id = str(artifacts[0].get("id") or "")
|
||||
if transcript_id:
|
||||
break
|
||||
if not transcript_id:
|
||||
print("Starting async transcript…")
|
||||
client.create_async_transcript(str(recording_id))
|
||||
time.sleep(10)
|
||||
else:
|
||||
break
|
||||
|
||||
if not transcript_id:
|
||||
raise SystemExit("Timed out waiting for transcript — webhook may still deliver it")
|
||||
|
||||
artifact = client.get_transcript(transcript_id)
|
||||
download_url = (artifact.get("data") or {}).get("download_url")
|
||||
if not download_url:
|
||||
raise SystemExit("Transcript ready but no download_url")
|
||||
|
||||
raw = client.download_json(download_url)
|
||||
text = transcript_download_to_text(raw)
|
||||
if not text.strip():
|
||||
raise SystemExit("Transcript download was empty")
|
||||
|
||||
result = run_post_transcript_pipeline(meeting_id, text)
|
||||
print("Recovered:", result)
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
raise SystemExit("Usage: recover_recall_meeting.py <meeting_id> [bot_id]")
|
||||
recover(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else None)
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Non-binding smoke checks for Recall integration boundaries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
_EXAMPLE_DIR = Path(__file__).resolve().parent
|
||||
_REPO_SRC = _EXAMPLE_DIR.parents[2] / "src"
|
||||
for _p in (_EXAMPLE_DIR, str(_REPO_SRC)):
|
||||
if str(_p) not in sys.path:
|
||||
sys.path.insert(0, str(_p))
|
||||
|
||||
|
||||
def _sign_payload(secret: str, body: str) -> dict[str, str]:
|
||||
key = base64.b64decode(secret.removeprefix("whsec_"))
|
||||
msg_id = "msg_smoke_test"
|
||||
timestamp = "1700000000"
|
||||
to_sign = f"{msg_id}.{timestamp}.{body}"
|
||||
signature = base64.b64encode(
|
||||
hmac.new(key, to_sign.encode("utf-8"), hashlib.sha256).digest()
|
||||
).decode("ascii")
|
||||
return {
|
||||
"webhook-id": msg_id,
|
||||
"webhook-timestamp": timestamp,
|
||||
"webhook-signature": f"v1,{signature}",
|
||||
}
|
||||
|
||||
|
||||
def run_smoke() -> None:
|
||||
os.environ.setdefault("RECALL_REGION", "eu-central-1")
|
||||
os.environ.setdefault("RECALL_API_KEY", "smoke-test-key")
|
||||
os.environ.setdefault("RECALL_WEBHOOK_VERIFICATION_SECRET", "whsec_" + base64.b64encode(b"smoke-secret-key-32-bytes-long!!").decode())
|
||||
os.environ.setdefault("RECALL_WORKSPACE_ID", "smoke-workspace")
|
||||
os.environ.setdefault("PUBLIC_API_BASE_URL", "https://smoke.example.test")
|
||||
|
||||
import app as meeting_app # noqa: WPS433
|
||||
from meeting_integrations.recall.config import load_recall_settings
|
||||
from meeting_integrations.recall.verify import verify_request_from_recall
|
||||
|
||||
settings = load_recall_settings()
|
||||
assert settings.api_key == "smoke-test-key"
|
||||
|
||||
body = json.dumps({"event": "bot.joining_call", "data": {"bot": {"id": "b1", "metadata": {"meeting_id": "m1"}}}})
|
||||
headers = _sign_payload(settings.webhook_verification_secret, body)
|
||||
verify_request_from_recall(
|
||||
secret=settings.webhook_verification_secret,
|
||||
headers=headers,
|
||||
payload=body,
|
||||
)
|
||||
|
||||
client = TestClient(meeting_app.app)
|
||||
assert client.get("/health").status_code == 200
|
||||
|
||||
bad = client.post("/webhooks/recall", content=body, headers={"webhook-id": "x"})
|
||||
assert bad.status_code == 401
|
||||
|
||||
ok = client.post("/webhooks/recall", content=body, headers=headers)
|
||||
assert ok.status_code == 200
|
||||
|
||||
with patch(
|
||||
"recall_routes.schedule_recall_bot",
|
||||
return_value={
|
||||
"meeting_id": "m-smoke",
|
||||
"recall_bot_id": "bot-smoke",
|
||||
"status": "scheduled",
|
||||
},
|
||||
):
|
||||
schedule = client.post(
|
||||
"/api/recall/bots",
|
||||
json={"meeting_url": "https://zoom.us/j/123", "title": "Smoke"},
|
||||
)
|
||||
assert schedule.status_code == 201
|
||||
|
||||
print("Recall smoke checks passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_smoke()
|
||||
@@ -0,0 +1,112 @@
|
||||
# Start meeting-agent (Part B) + Cloudflare tunnel for Recall webhooks.
|
||||
# Usage: .\start_dev.ps1
|
||||
# Optional: $env:TEST_MEETING_URL = "https://meet.google.com/..." ; .\start_dev.ps1 -ScheduleBot
|
||||
|
||||
param(
|
||||
[switch]$ScheduleBot,
|
||||
[string]$MeetingUrl = $env:TEST_MEETING_URL,
|
||||
[int]$Port = 8000
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
Set-Location $Root
|
||||
|
||||
function Load-DotEnv {
|
||||
$envFile = Join-Path $Root ".env"
|
||||
if (-not (Test-Path $envFile)) { return }
|
||||
Get-Content $envFile | ForEach-Object {
|
||||
$line = $_.Trim()
|
||||
if (-not $line -or $line.StartsWith("#") -or $line -notmatch "=") { return }
|
||||
$key, $value = $line -split "=", 2
|
||||
if ($key -and -not [string]::IsNullOrWhiteSpace($key) -and -not (Get-Item "Env:$key" -ErrorAction SilentlyContinue)) {
|
||||
Set-Item -Path "Env:$key" -Value $value.Trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Test-PortListening([int]$p) {
|
||||
try {
|
||||
$r = Invoke-WebRequest -Uri "http://127.0.0.1:$p/api/recall/config" -UseBasicParsing -TimeoutSec 2
|
||||
return $r.StatusCode -eq 200
|
||||
} catch { return $false }
|
||||
}
|
||||
|
||||
function Update-PublicUrl([string]$baseUrl) {
|
||||
$envFile = Join-Path $Root ".env"
|
||||
$content = Get-Content $envFile -Raw
|
||||
if ($content -match "PUBLIC_API_BASE_URL=.*") {
|
||||
$newContent = $content -replace "PUBLIC_API_BASE_URL=.*", "PUBLIC_API_BASE_URL=$baseUrl"
|
||||
} else {
|
||||
$newContent = $content.TrimEnd() + "`nPUBLIC_API_BASE_URL=$baseUrl`n"
|
||||
}
|
||||
Set-Content -Path $envFile -Value $newContent -NoNewline
|
||||
$env:PUBLIC_API_BASE_URL = $baseUrl
|
||||
Write-Host "Updated .env PUBLIC_API_BASE_URL=$baseUrl"
|
||||
}
|
||||
|
||||
Load-DotEnv
|
||||
$env:MEETING_AGENT_PORT = "$Port"
|
||||
|
||||
if (-not (Test-PortListening $Port)) {
|
||||
Write-Host "Starting meeting-agent on port $Port..."
|
||||
Start-Process -FilePath "py" -ArgumentList "-3.13", "app.py" -WorkingDirectory $Root -WindowStyle Minimized
|
||||
$deadline = (Get-Date).AddSeconds(45)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
if (Test-PortListening $Port) { break }
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
if (-not (Test-PortListening $Port)) {
|
||||
throw "App did not become ready on http://127.0.0.1:$Port"
|
||||
}
|
||||
Write-Host "App ready: http://127.0.0.1:$Port"
|
||||
} else {
|
||||
Write-Host "App already running on port $Port"
|
||||
}
|
||||
|
||||
$public = $env:PUBLIC_API_BASE_URL
|
||||
try {
|
||||
$null = Invoke-WebRequest -Uri "$public/api/recall/config" -UseBasicParsing -TimeoutSec 5
|
||||
Write-Host "Tunnel OK: $public"
|
||||
} catch {
|
||||
Write-Host "Starting Cloudflare tunnel (quick tunnel - URL may change)..."
|
||||
$log = Join-Path $Root ".tunnel.log"
|
||||
$logErr = "${log}.err"
|
||||
if (Test-Path $log) { Remove-Item $log -Force }
|
||||
if (Test-Path $logErr) { Remove-Item $logErr -Force }
|
||||
Start-Process -FilePath "cloudflared" -ArgumentList "tunnel", "--url", "http://127.0.0.1:$Port" `
|
||||
-RedirectStandardOutput $log -RedirectStandardError $logErr -WindowStyle Minimized
|
||||
$deadline = (Get-Date).AddSeconds(90)
|
||||
$newUrl = $null
|
||||
while ((Get-Date) -lt $deadline -and -not $newUrl) {
|
||||
Start-Sleep -Seconds 2
|
||||
foreach ($path in @($logErr, $log)) {
|
||||
if (-not (Test-Path $path)) { continue }
|
||||
$match = Select-String -Path $path -Pattern "https://[a-z0-9-]+\.trycloudflare\.com" | Select-Object -First 1
|
||||
if ($match) {
|
||||
$newUrl = $match.Matches[0].Value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (-not $newUrl) { throw "Could not read tunnel URL from $logErr (or $log). Check cloudflared is installed." }
|
||||
Update-PublicUrl $newUrl
|
||||
Write-Host "Tunnel ready: $newUrl"
|
||||
Write-Host "If Recall webhook URL changed, update it in Recall dashboard Webhooks"
|
||||
}
|
||||
|
||||
$config = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/api/recall/config"
|
||||
Write-Host "Recall configured: $($config.configured)"
|
||||
|
||||
if ($ScheduleBot) {
|
||||
if (-not $MeetingUrl) {
|
||||
Write-Host "No TEST_MEETING_URL - skip bot schedule. Set env TEST_MEETING_URL or pass -MeetingUrl"
|
||||
} else {
|
||||
$body = @{ meeting_url = $MeetingUrl; title = "Part B live transcript test" } | ConvertTo-Json
|
||||
$result = Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:$Port/api/recall/bots" `
|
||||
-ContentType "application/json" -Body $body
|
||||
Write-Host "Bot scheduled:" ($result | ConvertTo-Json -Compress)
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Done. Dashboard: http://127.0.0.1:$Port"
|
||||
@@ -0,0 +1,16 @@
|
||||
# Voice Agent — local secrets. Never commit.
|
||||
|
||||
OPENAI_API_KEY=
|
||||
|
||||
VOICE_AGENT_PORT=8001
|
||||
|
||||
# Voice telephony provider
|
||||
VOICE_API_BASE=
|
||||
VOICE_PRIVATE_API_KEY=
|
||||
VOICE_SERVER_URL_SECRET=
|
||||
VOICE_ASSISTANT_ID=
|
||||
VOICE_PHONE_NUMBER_ID=
|
||||
|
||||
# Public HTTPS URL for provider webhook (Cloudflare tunnel)
|
||||
# cloudflared tunnel --url http://127.0.0.1:8001
|
||||
PUBLIC_API_BASE_URL=
|
||||
@@ -0,0 +1,54 @@
|
||||
# Voice Agent example — PraisonAIUI
|
||||
|
||||
Phone and web voice agents using an external telephony/voice platform for STT/TTS/calls and PraisonAI for tools + dashboard.
|
||||
|
||||
## Quick start
|
||||
|
||||
```powershell
|
||||
cd examples/python/voice-agent
|
||||
copy .env.example .env
|
||||
# Fill VOICE_API_BASE, VOICE_PRIVATE_API_KEY, VOICE_ASSISTANT_ID, etc.
|
||||
|
||||
# Fully automatic (app + tunnel + provider config):
|
||||
.\setup_all.ps1
|
||||
|
||||
# Or step by step:
|
||||
.\start_dev.ps1
|
||||
```
|
||||
|
||||
Dashboard: http://127.0.0.1:8001
|
||||
|
||||
## Provider setup
|
||||
|
||||
1. Create an **assistant** with tools `get_current_time` and `echo_message`
|
||||
2. Set **webhook URL** to `{PUBLIC_API_BASE_URL}/webhooks/voice`
|
||||
3. Set **webhook secret header** `X-Voice-Webhook-Secret` → same as `VOICE_SERVER_URL_SECRET` in `.env`
|
||||
4. Add a phone number and copy `VOICE_PHONE_NUMBER_ID`
|
||||
|
||||
Or run `py -3.13 setup_voice_provider.py` after `start_dev.ps1`.
|
||||
|
||||
## API
|
||||
|
||||
| Route | Method | Purpose |
|
||||
|-------|--------|---------|
|
||||
| `/api/voice/calls` | POST | Outbound call `{ "customer_number": "+1..." }` |
|
||||
| `/api/voice/calls` | GET | List stored calls |
|
||||
| `/api/voice/calls/{id}` | GET | Call detail + transcript |
|
||||
| `/webhooks/voice` | POST | Provider webhooks (tool-calls, transcript, end-of-call) |
|
||||
| `/api/voice/config` | GET | Config status |
|
||||
|
||||
## Architecture
|
||||
|
||||
External platform handles voice pipeline (phone, STT, TTS, turn-taking). This app handles:
|
||||
|
||||
- **tool-calls** → PraisonAI-registered tools (`integrations/voice/tools.py`)
|
||||
- **transcript** / **end-of-call-report** → SQLite store + dashboard pages
|
||||
- **assistant-request** → returns `VOICE_ASSISTANT_ID`
|
||||
|
||||
Same pattern as `examples/python/meeting-agent` (Recall webhooks).
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
pytest tests/unit/test_voice_agent.py -v
|
||||
```
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Voice Agent — PraisonAIUI example with telephony webhooks.
|
||||
|
||||
- Outbound calls → POST /api/voice/calls
|
||||
- Provider webhooks → POST /webhooks/voice (tool-calls, transcript, end-of-call)
|
||||
- Dashboard → Calls list + call detail + chat
|
||||
|
||||
Run:
|
||||
cd examples/python/voice-agent
|
||||
pip install httpx
|
||||
copy .env.example .env
|
||||
.\\start_dev.ps1
|
||||
python app.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from starlette.routing import Route
|
||||
from voice_routes import (
|
||||
api_voice_call_detail,
|
||||
api_voice_config_status,
|
||||
api_voice_create_call,
|
||||
api_voice_list_calls,
|
||||
webhook_voice,
|
||||
)
|
||||
|
||||
import praisonaiui as aiui
|
||||
from praisonaiui.server import create_app
|
||||
|
||||
_EXAMPLE_DIR = Path(__file__).resolve().parent
|
||||
if str(_EXAMPLE_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLE_DIR))
|
||||
|
||||
|
||||
def _load_local_env() -> None:
|
||||
env_path = _EXAMPLE_DIR / ".env"
|
||||
if not env_path.is_file():
|
||||
return
|
||||
for raw in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key, value = key.strip(), value.strip()
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
_load_local_env()
|
||||
|
||||
aiui.set_pages(["chat", "voice-calls", "call-detail", "config"])
|
||||
aiui.set_style("dashboard")
|
||||
|
||||
_agent: Any | None = None
|
||||
|
||||
|
||||
def get_agent():
|
||||
global _agent
|
||||
if _agent is not None:
|
||||
return _agent
|
||||
from integrations.voice.tools import echo_message, get_current_time, register_tool
|
||||
from praisonaiagents import Agent
|
||||
|
||||
register_tool("get_current_time", get_current_time)
|
||||
register_tool("echo_message", echo_message)
|
||||
|
||||
_agent = Agent(
|
||||
name="Voice Assistant",
|
||||
instructions=(
|
||||
"You help with voice and phone agent demos. "
|
||||
"Use tools when the caller asks for the time or wants a message echoed."
|
||||
),
|
||||
model="gpt-4o-mini",
|
||||
tools=[get_current_time, echo_message],
|
||||
)
|
||||
from praisonaiagents.escalation.loop_guard import LoopGuard, LoopGuardConfig
|
||||
|
||||
max_turn_sec = float(os.getenv("VOICE_AGENT_LOOP_GUARD_MAX_SEC", "600"))
|
||||
_agent._loop_guard = LoopGuard(
|
||||
LoopGuardConfig(enabled=True, max_time_per_turn=max_turn_sec)
|
||||
)
|
||||
return _agent
|
||||
|
||||
|
||||
def _demo_tool_reply(text: str) -> str | None:
|
||||
"""Fast path for demo tool queries — no LLM required."""
|
||||
from integrations.voice.tools import echo_message, get_current_time
|
||||
|
||||
lower = text.lower().strip()
|
||||
if any(p in lower for p in ("what time", "current time", "tell me the time", "time is it")):
|
||||
return str(get_current_time()["spoken"])
|
||||
if lower.startswith("echo "):
|
||||
return str(echo_message(text[5:])["spoken"])
|
||||
if lower.startswith("repeat "):
|
||||
return str(echo_message(text[7:])["spoken"])
|
||||
return None
|
||||
|
||||
|
||||
@aiui.reply
|
||||
async def on_message(message: str):
|
||||
"""Chat with the voice demo agent."""
|
||||
text = str(message).strip()
|
||||
if not text:
|
||||
return
|
||||
|
||||
demo = _demo_tool_reply(text)
|
||||
if demo is not None:
|
||||
await aiui.say(demo)
|
||||
return
|
||||
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
await aiui.say(
|
||||
"Set `OPENAI_API_KEY` for open-ended chat. "
|
||||
'Demo without API key: "What time is it?" or "Echo hello world".'
|
||||
)
|
||||
return
|
||||
await aiui.think("Running voice assistant...")
|
||||
agent = get_agent()
|
||||
response = await asyncio.to_thread(agent.chat, text)
|
||||
await aiui.say(str(response))
|
||||
|
||||
|
||||
def _list_calls() -> list[dict[str, Any]]:
|
||||
from integrations.voice.store import VoiceCallStore
|
||||
|
||||
rows = VoiceCallStore.list_calls()
|
||||
return [
|
||||
{
|
||||
"id": r["call_id"],
|
||||
"status": r["status"],
|
||||
"customer_number": r.get("customer_number") or "—",
|
||||
"transcript_preview": (r.get("transcript") or "")[:120],
|
||||
"updated_at": r.get("updated_at") or "",
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _pick_call(calls: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
live = next((c for c in calls if "progress" in (c.get("status") or "").lower()), None)
|
||||
return live or (calls[0] if calls else {"id": "—", "status": "none", "customer_number": "—", "transcript_preview": ""})
|
||||
|
||||
|
||||
@aiui.page("voice-calls", title="Voice calls", icon="📞", group="Voice", order=1)
|
||||
async def voice_calls_page():
|
||||
calls = _list_calls()
|
||||
rows = [[c["id"], c["customer_number"], c["status"], c["updated_at"]] for c in calls]
|
||||
return aiui.layout(
|
||||
[
|
||||
aiui.text("Outbound and inbound voice calls via telephony provider"),
|
||||
aiui.table(
|
||||
headers=["Call ID", "Customer", "Status", "Updated"],
|
||||
rows=rows or [["—", "—", "—", "—"]],
|
||||
),
|
||||
aiui.alert(
|
||||
"Start a call: POST /api/voice/calls with {\"customer_number\": \"+1...\"}. "
|
||||
"Set provider webhook URL to {PUBLIC_API_BASE_URL}/webhooks/voice",
|
||||
variant="info",
|
||||
title="Setup",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@aiui.page("call-detail", title="Call detail", icon="🎙️", group="Voice", order=2)
|
||||
async def call_detail_page():
|
||||
from integrations.voice.store import VoiceCallStore
|
||||
|
||||
calls = _list_calls()
|
||||
picked = _pick_call(calls)
|
||||
record = VoiceCallStore.get_call(picked["id"]) if picked.get("id") and picked["id"] != "—" else None
|
||||
transcript = (record or {}).get("transcript") or "(empty)"
|
||||
summary = (record or {}).get("summary") or "(not available yet)"
|
||||
status = (record or {}).get("status") or picked.get("status") or "unknown"
|
||||
return aiui.layout(
|
||||
[
|
||||
aiui.text(f"Call {picked.get('id', '—')}"),
|
||||
aiui.badge(status, variant="default"),
|
||||
aiui.tabs(
|
||||
[
|
||||
{"label": "Transcript", "children": [aiui.code_block(transcript, language="text")]},
|
||||
{"label": "Summary", "children": [aiui.text(summary)]},
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
app = create_app()
|
||||
app.routes[0:0] = [
|
||||
Route("/api/voice/calls", api_voice_create_call, methods=["POST"]),
|
||||
Route("/api/voice/calls", api_voice_list_calls, methods=["GET"]),
|
||||
Route("/api/voice/calls/{call_id}", api_voice_call_detail, methods=["GET"]),
|
||||
Route("/api/voice/config", api_voice_config_status, methods=["GET"]),
|
||||
Route("/webhooks/voice", webhook_voice, methods=["POST"]),
|
||||
]
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
host = os.environ.get("HOST", "127.0.0.1")
|
||||
port = int(os.environ.get("VOICE_AGENT_PORT", "8001"))
|
||||
uvicorn.run(app, host=host, port=port)
|
||||
@@ -0,0 +1 @@
|
||||
"""Voice agent integrations."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Voice telephony provider integration."""
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Minimal voice provider REST client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from integrations.voice.config import VoiceSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VoiceAPIError(RuntimeError):
|
||||
def __init__(self, status_code: int, detail: str):
|
||||
super().__init__(detail)
|
||||
self.status_code = status_code
|
||||
self.detail = detail
|
||||
|
||||
|
||||
class VoiceClient:
|
||||
"""HTTP client for voice provider calls and assistants."""
|
||||
|
||||
def __init__(self, settings: VoiceSettings):
|
||||
self._settings = settings
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {self._settings.api_key}",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
json_body: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
import httpx # lazy: optional dependency for the example (pip install httpx)
|
||||
|
||||
url = f"{self._settings.api_base.rstrip('/')}{path}"
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
response = client.request(method, url, headers=self._headers(), json=json_body)
|
||||
if response.status_code >= 400:
|
||||
raise VoiceAPIError(response.status_code, response.text[:500] or response.reason_phrase)
|
||||
if not response.content:
|
||||
return {}
|
||||
data = response.json()
|
||||
return data if isinstance(data, dict) else {"data": data}
|
||||
|
||||
def create_call(
|
||||
self,
|
||||
*,
|
||||
customer_number: str,
|
||||
assistant_id: str | None = None,
|
||||
phone_number_id: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
assistant_id = assistant_id or self._settings.default_assistant_id
|
||||
phone_number_id = phone_number_id or self._settings.default_phone_number_id
|
||||
if not assistant_id:
|
||||
raise VoiceAPIError(0, "assistant_id is required (set VOICE_ASSISTANT_ID or pass explicitly)")
|
||||
if not phone_number_id:
|
||||
raise VoiceAPIError(0, "phone_number_id is required (set VOICE_PHONE_NUMBER_ID or pass explicitly)")
|
||||
body: dict[str, Any] = {
|
||||
"assistantId": assistant_id,
|
||||
"phoneNumberId": phone_number_id,
|
||||
"customer": {"number": customer_number},
|
||||
}
|
||||
if metadata:
|
||||
body["metadata"] = metadata
|
||||
return self._request("POST", "/call", json_body=body)
|
||||
|
||||
def get_call(self, call_id: str) -> dict[str, Any]:
|
||||
return self._request("GET", f"/call/{call_id}")
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Voice provider configuration loaded from environment."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class VoiceConfigError(ValueError):
|
||||
"""Missing or invalid voice provider configuration."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VoiceSettings:
|
||||
api_key: str
|
||||
server_url_secret: str | None
|
||||
public_api_base_url: str | None
|
||||
default_assistant_id: str | None
|
||||
default_phone_number_id: str | None
|
||||
api_base: str
|
||||
|
||||
@property
|
||||
def webhook_url(self) -> str | None:
|
||||
if not self.public_api_base_url:
|
||||
return None
|
||||
return f"{self.public_api_base_url.rstrip('/')}/webhooks/voice"
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.api_key and self.api_base)
|
||||
|
||||
|
||||
def _read_dotenv(name: str) -> str | None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
env_path = root / ".env"
|
||||
if not env_path.is_file():
|
||||
return None
|
||||
prefix = f"{name}="
|
||||
for raw in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if line.startswith(prefix):
|
||||
return line[len(prefix) :].strip() or None
|
||||
return None
|
||||
|
||||
|
||||
def _env(name: str) -> str:
|
||||
return os.getenv(name, "").strip() or (_read_dotenv(name) or "")
|
||||
|
||||
|
||||
def load_voice_settings(*, require_key: bool = True) -> VoiceSettings:
|
||||
api_key = _env("VOICE_PRIVATE_API_KEY") or _env("VOICE_API_KEY")
|
||||
api_base = _env("VOICE_API_BASE")
|
||||
if require_key and not api_key:
|
||||
raise VoiceConfigError("VOICE_PRIVATE_API_KEY (or VOICE_API_KEY) is required")
|
||||
if require_key and not api_base:
|
||||
raise VoiceConfigError("VOICE_API_BASE is required")
|
||||
return VoiceSettings(
|
||||
api_key=api_key,
|
||||
server_url_secret=_env("VOICE_SERVER_URL_SECRET") or None,
|
||||
public_api_base_url=_env("PUBLIC_API_BASE_URL") or None,
|
||||
default_assistant_id=_env("VOICE_ASSISTANT_ID") or None,
|
||||
default_phone_number_id=_env("VOICE_PHONE_NUMBER_ID") or None,
|
||||
api_base=api_base.rstrip("/"),
|
||||
)
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Process voice provider webhook events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from integrations.voice.config import VoiceSettings
|
||||
from integrations.voice.store import VoiceCallStore, webhook_event_key
|
||||
from integrations.voice.tools import execute_tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _message(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
msg = payload.get("message")
|
||||
return msg if isinstance(msg, dict) else payload
|
||||
|
||||
|
||||
def _call_id(payload: dict[str, Any]) -> str | None:
|
||||
msg = _message(payload)
|
||||
call = msg.get("call")
|
||||
if isinstance(call, dict) and call.get("id"):
|
||||
return str(call["id"])
|
||||
return None
|
||||
|
||||
|
||||
def _customer_number(payload: dict[str, Any]) -> str | None:
|
||||
msg = _message(payload)
|
||||
call = msg.get("call") if isinstance(msg.get("call"), dict) else {}
|
||||
customer = call.get("customer") if isinstance(call, dict) else {}
|
||||
if isinstance(customer, dict) and customer.get("number"):
|
||||
return str(customer["number"])
|
||||
return None
|
||||
|
||||
|
||||
def process_voice_webhook(
|
||||
event_type: str, payload: dict[str, Any], *, settings: VoiceSettings
|
||||
) -> dict[str, Any] | None:
|
||||
"""Handle a verified provider webhook POST. Returns response body when required."""
|
||||
event_key = webhook_event_key(event_type, payload)
|
||||
if event_type not in ("tool-calls", "assistant-request", "transfer-destination-request", "knowledge-base-request"):
|
||||
if not VoiceCallStore.mark_event_processed(event_key, event_type):
|
||||
logger.info("Skipping duplicate voice event %s", event_type)
|
||||
return None
|
||||
|
||||
msg = _message(payload)
|
||||
call_id = _call_id(payload)
|
||||
|
||||
if event_type == "assistant-request":
|
||||
assistant_id = settings.default_assistant_id
|
||||
if not assistant_id:
|
||||
return {"error": "No assistant configured on server"}
|
||||
return {"assistantId": assistant_id}
|
||||
|
||||
if event_type == "tool-calls" and call_id:
|
||||
tool_calls = msg.get("toolCallList") or []
|
||||
results = []
|
||||
for item in tool_calls:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
tool_call_id = str(item.get("id") or "")
|
||||
name = str(item.get("name") or "")
|
||||
params = item.get("parameters") if isinstance(item.get("parameters"), dict) else {}
|
||||
result = execute_tool(name, params)
|
||||
results.append({"toolCallId": tool_call_id, "name": name, "result": result})
|
||||
VoiceCallStore.upsert_call(call_id, status="in-progress", customer_number=_customer_number(payload))
|
||||
return {"results": results}
|
||||
|
||||
if event_type == "status-update" and call_id:
|
||||
status = str(msg.get("status") or "unknown")
|
||||
VoiceCallStore.upsert_call(call_id, status=status, customer_number=_customer_number(payload))
|
||||
return None
|
||||
|
||||
if event_type == "transcript" and call_id:
|
||||
role = str(msg.get("role") or "unknown")
|
||||
text = str(msg.get("transcript") or "").strip()
|
||||
if text and str(msg.get("transcriptType") or "final") == "final":
|
||||
VoiceCallStore.append_transcript_line(call_id, f"{role}: {text}")
|
||||
return None
|
||||
|
||||
if event_type == "end-of-call-report" and call_id:
|
||||
artifact = msg.get("artifact") if isinstance(msg.get("artifact"), dict) else {}
|
||||
transcript = artifact.get("transcript") if isinstance(artifact, dict) else None
|
||||
summary = str(msg.get("summary") or "")
|
||||
ended = str(msg.get("endedReason") or "ended")
|
||||
VoiceCallStore.upsert_call(
|
||||
call_id,
|
||||
status=f"ended ({ended})",
|
||||
customer_number=_customer_number(payload),
|
||||
transcript=str(transcript) if transcript else None,
|
||||
summary=summary or None,
|
||||
metadata={"artifact": artifact},
|
||||
)
|
||||
return None
|
||||
|
||||
if event_type == "conversation-update" and call_id:
|
||||
messages = msg.get("messages") or []
|
||||
lines = []
|
||||
for item in messages:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
role = item.get("role") or "unknown"
|
||||
text = item.get("message") or item.get("content") or ""
|
||||
if text:
|
||||
lines.append(f"{role}: {text}")
|
||||
if lines:
|
||||
VoiceCallStore.upsert_call(
|
||||
call_id, transcript="\n".join(lines), customer_number=_customer_number(payload)
|
||||
)
|
||||
return None
|
||||
|
||||
logger.debug("Unhandled voice event %s: %s", event_type, json.dumps(payload)[:300])
|
||||
return None
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Persist voice call state and webhook idempotency."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from contextlib import closing
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _state_db_path() -> str:
|
||||
env = os.getenv("PRAISONAI_VOICE_DIR")
|
||||
base = Path(env) if env else Path.home() / ".praisonai" / "voice"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
return str(base / "voice_state.db")
|
||||
|
||||
|
||||
def _connect() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(_state_db_path())
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS voice_calls (
|
||||
call_id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
customer_number TEXT,
|
||||
transcript TEXT NOT NULL DEFAULT '',
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS voice_webhook_events (
|
||||
event_key TEXT PRIMARY KEY,
|
||||
event_type TEXT NOT NULL,
|
||||
processed_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
return conn
|
||||
|
||||
|
||||
def webhook_event_key(event_type: str, payload: dict[str, Any]) -> str:
|
||||
message = payload.get("message") if isinstance(payload.get("message"), dict) else payload
|
||||
call = (message or {}).get("call") if isinstance(message, dict) else {}
|
||||
call_id = call.get("id") if isinstance(call, dict) else None
|
||||
if isinstance(message, dict):
|
||||
# Derive the key from the fields that actually distinguish one event from
|
||||
# the next. A truncated JSON dump is unsafe here: when a large static field
|
||||
# (e.g. ``call``) sorts before the discriminating field, successive events
|
||||
# collapse to the same prefix and later ones get dropped as "duplicates"
|
||||
# (e.g. a status-update stuck on "ringing").
|
||||
if event_type == "transcript":
|
||||
text = str(message.get("transcript") or "")
|
||||
role = str(message.get("role") or "")
|
||||
kind = str(message.get("transcriptType") or "")
|
||||
return f"{event_type}:{call_id}:{role}:{kind}:{text}"
|
||||
if event_type == "status-update":
|
||||
return f"{event_type}:{call_id}:{message.get('status')}"
|
||||
if event_type == "conversation-update":
|
||||
messages = message.get("messages")
|
||||
count = len(messages) if isinstance(messages, list) else 0
|
||||
return f"{event_type}:{call_id}:{count}"
|
||||
if event_type == "end-of-call-report":
|
||||
return f"{event_type}:{call_id}:{message.get('endedReason')}"
|
||||
if call_id:
|
||||
return f"{event_type}:{call_id}:{json.dumps(message, sort_keys=True)[:200]}"
|
||||
return f"{event_type}:{json.dumps(payload, sort_keys=True)[:300]}"
|
||||
|
||||
|
||||
class VoiceCallStore:
|
||||
@staticmethod
|
||||
def mark_event_processed(event_key: str, event_type: str) -> bool:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with closing(_connect()) as conn, conn:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM voice_webhook_events WHERE event_key = ?",
|
||||
(event_key,),
|
||||
).fetchone()
|
||||
if row:
|
||||
return False
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO voice_webhook_events (event_key, event_type, processed_at)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(event_key, event_type, now),
|
||||
)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def upsert_call(
|
||||
call_id: str,
|
||||
*,
|
||||
status: str | None = None,
|
||||
customer_number: str | None = None,
|
||||
transcript: str | None = None,
|
||||
summary: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with closing(_connect()) as conn, conn:
|
||||
row = conn.execute(
|
||||
"SELECT transcript, summary, metadata_json, customer_number, status FROM voice_calls WHERE call_id = ?",
|
||||
(call_id,),
|
||||
).fetchone()
|
||||
existing_transcript = row[0] if row else ""
|
||||
existing_summary = row[1] if row else ""
|
||||
existing_meta = json.loads(row[2]) if row and row[2] else {}
|
||||
existing_number = row[3] if row else None
|
||||
existing_status = row[4] if row else None
|
||||
# Preserve prior status when the caller doesn't supply one (e.g. transcript
|
||||
# / conversation-update events) so partial updates don't clobber live state.
|
||||
final_status = status if status is not None else (existing_status or "unknown")
|
||||
merged_meta = {**existing_meta, **(metadata or {})}
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO voice_calls (
|
||||
call_id, status, customer_number, transcript, summary, metadata_json, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(call_id) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
customer_number = COALESCE(excluded.customer_number, voice_calls.customer_number),
|
||||
transcript = excluded.transcript,
|
||||
summary = COALESCE(excluded.summary, voice_calls.summary),
|
||||
metadata_json = excluded.metadata_json,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
call_id,
|
||||
final_status,
|
||||
customer_number or existing_number,
|
||||
transcript if transcript is not None else existing_transcript,
|
||||
summary if summary is not None else existing_summary,
|
||||
json.dumps(merged_meta, sort_keys=True),
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def append_transcript_line(call_id: str, line: str) -> str:
|
||||
with closing(_connect()) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT transcript FROM voice_calls WHERE call_id = ?",
|
||||
(call_id,),
|
||||
).fetchone()
|
||||
existing = row[0] if row else ""
|
||||
full = f"{existing}\n{line}".strip() if existing else line
|
||||
VoiceCallStore.upsert_call(call_id, transcript=full)
|
||||
return full
|
||||
|
||||
@staticmethod
|
||||
def list_calls(limit: int = 50) -> list[dict[str, Any]]:
|
||||
with closing(_connect()) as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT call_id, status, customer_number, transcript, summary, metadata_json, updated_at
|
||||
FROM voice_calls ORDER BY updated_at DESC LIMIT ?
|
||||
""",
|
||||
(max(1, limit),),
|
||||
).fetchall()
|
||||
out: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
out.append(
|
||||
{
|
||||
"call_id": row[0],
|
||||
"status": row[1],
|
||||
"customer_number": row[2],
|
||||
"transcript": row[3],
|
||||
"summary": row[4],
|
||||
"metadata": json.loads(row[5] or "{}"),
|
||||
"updated_at": row[6],
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def get_call(call_id: str) -> dict[str, Any] | None:
|
||||
with closing(_connect()) as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT call_id, status, customer_number, transcript, summary, metadata_json, updated_at
|
||||
FROM voice_calls WHERE call_id = ?
|
||||
""",
|
||||
(call_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"call_id": row[0],
|
||||
"status": row[1],
|
||||
"customer_number": row[2],
|
||||
"transcript": row[3],
|
||||
"summary": row[4],
|
||||
"metadata": json.loads(row[5] or "{}"),
|
||||
"updated_at": row[6],
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Tools callable from voice provider ``tool-calls`` webhook events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
def get_current_time() -> dict[str, Any]:
|
||||
"""Return current UTC time for voice agent demos."""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
return {"utc": now, "spoken": f"The current UTC time is {now}"}
|
||||
|
||||
|
||||
def echo_message(message: str = "") -> dict[str, Any]:
|
||||
"""Echo a message back to the caller."""
|
||||
text = message.strip() or "nothing"
|
||||
return {"message": text, "spoken": f"You said {text}"}
|
||||
|
||||
|
||||
_TOOL_REGISTRY: dict[str, Callable[..., Any]] = {
|
||||
"get_current_time": get_current_time,
|
||||
"echo_message": echo_message,
|
||||
}
|
||||
|
||||
|
||||
def register_tool(name: str, fn: Callable[..., Any]) -> None:
|
||||
_TOOL_REGISTRY[name] = fn
|
||||
|
||||
|
||||
def execute_tool(name: str, parameters: dict[str, Any] | None) -> str:
|
||||
"""Run a registered tool and return a JSON string for the provider."""
|
||||
fn = _TOOL_REGISTRY.get(name)
|
||||
if fn is None:
|
||||
return json.dumps({"error": f"Unknown tool: {name}"})
|
||||
try:
|
||||
result = fn(**(parameters or {}))
|
||||
if isinstance(result, str):
|
||||
return result
|
||||
return json.dumps(result)
|
||||
except TypeError:
|
||||
try:
|
||||
result = fn(parameters or {})
|
||||
return json.dumps(result)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return json.dumps({"error": str(exc)})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return json.dumps({"error": str(exc)})
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Verify incoming voice provider webhook requests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
|
||||
_SECRET_HEADERS = ("x-voice-webhook-secret", "x-webhook-secret")
|
||||
|
||||
|
||||
class VerificationError(ValueError):
|
||||
"""Webhook secret verification failed."""
|
||||
|
||||
|
||||
def verify_webhook_request(*, secret: str | None, headers: dict[str, str]) -> None:
|
||||
"""Validate webhook secret header when ``VOICE_SERVER_URL_SECRET`` is configured."""
|
||||
if not secret:
|
||||
return
|
||||
lowered = {k.lower(): v for k, v in headers.items()}
|
||||
incoming = next((lowered[name] for name in _SECRET_HEADERS if lowered.get(name)), None)
|
||||
if not incoming or not hmac.compare_digest(incoming, secret):
|
||||
raise VerificationError("Invalid or missing webhook secret header")
|
||||
@@ -0,0 +1,11 @@
|
||||
# One command: start app + tunnel + configure voice provider automatically.
|
||||
Set-Location $PSScriptRoot
|
||||
if (-not (Test-Path .env)) { Copy-Item .env.example .env; Write-Host "Created .env — add VOICE_* keys" ; exit 1 }
|
||||
|
||||
.\start_dev.ps1
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Configuring voice provider via API..." -ForegroundColor Cyan
|
||||
py -3.13 setup_voice_provider.py
|
||||
exit $LASTEXITCODE
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Configure voice provider assistant: tools + webhook URL + auth header.
|
||||
|
||||
Usage:
|
||||
cd examples/python/voice-agent
|
||||
py -3.13 setup_voice_provider.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent
|
||||
ENV_PATH = _ROOT / ".env"
|
||||
WEBHOOK_SECRET_HEADER = "X-Voice-Webhook-Secret"
|
||||
|
||||
|
||||
def _load_env() -> None:
|
||||
if not ENV_PATH.is_file():
|
||||
return
|
||||
for raw in ENV_PATH.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key, value = key.strip(), value.strip()
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def _write_env_value(key: str, value: str) -> None:
|
||||
lines = ENV_PATH.read_text(encoding="utf-8").splitlines() if ENV_PATH.is_file() else []
|
||||
updated = False
|
||||
new_lines: list[str] = []
|
||||
for line in lines:
|
||||
if line.strip().startswith(f"{key}="):
|
||||
new_lines.append(f"{key}={value}")
|
||||
updated = True
|
||||
else:
|
||||
new_lines.append(line)
|
||||
if not updated:
|
||||
new_lines.append(f"{key}={value}")
|
||||
ENV_PATH.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def _api_key() -> str:
|
||||
return os.getenv("VOICE_PRIVATE_API_KEY", "").strip() or os.getenv("VOICE_API_KEY", "").strip()
|
||||
|
||||
|
||||
def _api_base() -> str:
|
||||
return os.getenv("VOICE_API_BASE", "").strip().rstrip("/")
|
||||
|
||||
|
||||
def _request(
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
api_key: str,
|
||||
api_base: str,
|
||||
body: dict | None = None,
|
||||
) -> dict | list | None:
|
||||
url = f"{api_base}{path}"
|
||||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||
response = httpx.request(method, url, headers=headers, json=body, timeout=30.0)
|
||||
if response.status_code >= 400:
|
||||
print(f"ERROR {method} {path} -> {response.status_code}")
|
||||
print(response.text[:1500])
|
||||
return None
|
||||
if not response.content:
|
||||
return {}
|
||||
return response.json()
|
||||
|
||||
|
||||
def _tool_specs() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_current_time",
|
||||
"description": "Returns the current UTC time when the caller asks for the time.",
|
||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||
},
|
||||
"async": False,
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "echo_message",
|
||||
"description": "Repeats back a message the caller provides.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "The message to repeat back to the caller.",
|
||||
}
|
||||
},
|
||||
"required": ["message"],
|
||||
},
|
||||
},
|
||||
"async": False,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def ensure_server_secret() -> str:
|
||||
secret = os.getenv("VOICE_SERVER_URL_SECRET", "").strip()
|
||||
if not secret:
|
||||
secret = str(uuid.uuid4())
|
||||
_write_env_value("VOICE_SERVER_URL_SECRET", secret)
|
||||
print("Generated VOICE_SERVER_URL_SECRET")
|
||||
return secret
|
||||
|
||||
|
||||
def ensure_tools(api_key: str, api_base: str, assistant_id: str) -> list[str]:
|
||||
existing = _request("GET", "/tool", api_key=api_key, api_base=api_base)
|
||||
by_name: dict[str, str] = {}
|
||||
if isinstance(existing, list):
|
||||
for item in existing:
|
||||
fn = item.get("function") if isinstance(item, dict) else None
|
||||
if isinstance(fn, dict) and fn.get("name") and item.get("id"):
|
||||
by_name[str(fn["name"])] = str(item["id"])
|
||||
|
||||
tool_ids: list[str] = []
|
||||
for spec in _tool_specs():
|
||||
name = spec["function"]["name"]
|
||||
if name in by_name:
|
||||
tool_id = by_name[name]
|
||||
print(f"Reusing tool {name}: {tool_id}")
|
||||
else:
|
||||
created = _request("POST", "/tool", api_key=api_key, api_base=api_base, body=spec)
|
||||
if not isinstance(created, dict) or not created.get("id"):
|
||||
raise RuntimeError(f"Failed to create tool {name}")
|
||||
tool_id = str(created["id"])
|
||||
print(f"Created tool {name}: {tool_id}")
|
||||
tool_ids.append(tool_id)
|
||||
|
||||
assistant = _request("GET", f"/assistant/{assistant_id}", api_key=api_key, api_base=api_base)
|
||||
if not isinstance(assistant, dict):
|
||||
raise RuntimeError("Could not load assistant")
|
||||
|
||||
model = dict(assistant.get("model") or {})
|
||||
if not model.get("provider"):
|
||||
raise RuntimeError("Assistant has no model.provider — configure a model in the provider dashboard first")
|
||||
|
||||
existing_ids = [str(x) for x in (model.get("toolIds") or [])]
|
||||
model["toolIds"] = list(dict.fromkeys(existing_ids + tool_ids))
|
||||
|
||||
messages = list(model.get("messages") or [])
|
||||
system_hint = (
|
||||
"You are a helpful voice assistant. When the caller asks for the time, "
|
||||
"call get_current_time. When they ask you to repeat something, call echo_message."
|
||||
)
|
||||
if not messages:
|
||||
model["messages"] = [{"role": "system", "content": system_hint}]
|
||||
else:
|
||||
for msg in messages:
|
||||
if isinstance(msg, dict) and msg.get("role") == "system":
|
||||
content = str(msg.get("content") or "")
|
||||
if "get_current_time" not in content:
|
||||
msg["content"] = f"{content.rstrip()}\n\n{system_hint}"
|
||||
break
|
||||
else:
|
||||
messages.insert(0, {"role": "system", "content": system_hint})
|
||||
model["messages"] = messages
|
||||
|
||||
patched = _request(
|
||||
"PATCH",
|
||||
f"/assistant/{assistant_id}",
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
body={"model": model},
|
||||
)
|
||||
if patched is None:
|
||||
raise RuntimeError("Failed to attach tools to assistant")
|
||||
|
||||
print("Attached tool IDs:", model["toolIds"])
|
||||
return tool_ids
|
||||
|
||||
|
||||
def ensure_webhook(api_key: str, api_base: str, assistant_id: str, webhook_url: str, secret: str) -> None:
|
||||
assistant = _request("GET", f"/assistant/{assistant_id}", api_key=api_key, api_base=api_base)
|
||||
if not isinstance(assistant, dict):
|
||||
raise RuntimeError("Could not load assistant for webhook setup")
|
||||
|
||||
server = dict(assistant.get("server") or {})
|
||||
server["url"] = webhook_url.rstrip("/")
|
||||
server["headers"] = {WEBHOOK_SECRET_HEADER: secret}
|
||||
server.pop("credentialId", None)
|
||||
server.pop("secret", None)
|
||||
|
||||
patched = _request(
|
||||
"PATCH",
|
||||
f"/assistant/{assistant_id}",
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
body={"server": server},
|
||||
)
|
||||
if patched is None:
|
||||
raise RuntimeError("Failed to configure assistant server URL")
|
||||
|
||||
print(f"Server URL: {webhook_url}")
|
||||
print(f"Webhook auth header: {WEBHOOK_SECRET_HEADER}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
_load_env()
|
||||
api_key = _api_key()
|
||||
api_base = _api_base()
|
||||
assistant_id = os.getenv("VOICE_ASSISTANT_ID", "").strip()
|
||||
public_base = os.getenv("PUBLIC_API_BASE_URL", "").strip().rstrip("/")
|
||||
|
||||
if not api_key:
|
||||
print("Set VOICE_PRIVATE_API_KEY in .env")
|
||||
return 1
|
||||
if not api_base:
|
||||
print("Set VOICE_API_BASE in .env")
|
||||
return 1
|
||||
if not assistant_id:
|
||||
print("Set VOICE_ASSISTANT_ID in .env")
|
||||
return 1
|
||||
if not public_base:
|
||||
print("Set PUBLIC_API_BASE_URL in .env (run .\\start_dev.ps1 first)")
|
||||
return 1
|
||||
|
||||
secret = ensure_server_secret()
|
||||
webhook_url = f"{public_base}/webhooks/voice"
|
||||
|
||||
try:
|
||||
ensure_tools(api_key, api_base, assistant_id)
|
||||
ensure_webhook(api_key, api_base, assistant_id, webhook_url, secret)
|
||||
except RuntimeError as exc:
|
||||
print(f"Setup failed: {exc}")
|
||||
return 1
|
||||
|
||||
print("Voice provider auto-setup complete.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,221 @@
|
||||
# Start voice-agent + Cloudflare tunnel for provider webhook URL.
|
||||
# Usage: .\start_dev.ps1
|
||||
# .\start_dev.ps1 -Restart # kill old app and reload code
|
||||
|
||||
param(
|
||||
[int]$Port = 8001,
|
||||
[switch]$Restart
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
Set-Location $Root
|
||||
|
||||
function Load-DotEnv {
|
||||
$envFile = Join-Path $Root ".env"
|
||||
if (-not (Test-Path $envFile)) { return }
|
||||
Get-Content $envFile | ForEach-Object {
|
||||
$line = $_.Trim()
|
||||
if (-not $line -or $line.StartsWith("#") -or $line -notmatch "=") { return }
|
||||
$key, $value = $line -split "=", 2
|
||||
if ($key -and -not [string]::IsNullOrWhiteSpace($key) -and -not (Get-Item "Env:$key" -ErrorAction SilentlyContinue)) {
|
||||
Set-Item -Path "Env:$key" -Value $value.Trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Test-PortListening([int]$p) {
|
||||
foreach ($path in @("/health/live", "/health", "/api/voice/config")) {
|
||||
try {
|
||||
$r = Invoke-WebRequest -Uri "http://127.0.0.1:$p$path" -UseBasicParsing -TimeoutSec 15
|
||||
if ($r.StatusCode -eq 200) { return $true }
|
||||
} catch { continue }
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Stop-PortProcess([int]$p) {
|
||||
try {
|
||||
$conns = Get-NetTCPConnection -LocalPort $p -State Listen -ErrorAction SilentlyContinue
|
||||
foreach ($conn in $conns) {
|
||||
if ($conn.OwningProcess) {
|
||||
Stop-Process -Id $conn.OwningProcess -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
if ($conns) {
|
||||
Start-Sleep -Seconds 2
|
||||
Write-Host "Stopped previous process on port $p"
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
function Clear-LogFile([string]$path) {
|
||||
if (-not (Test-Path $path)) { return }
|
||||
try {
|
||||
Remove-Item $path -Force -ErrorAction Stop
|
||||
} catch {
|
||||
try { Clear-Content $path -ErrorAction SilentlyContinue } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
function Start-VoiceApp([int]$p) {
|
||||
Write-Host "Starting voice-agent on port $p..."
|
||||
Stop-PortProcess $p
|
||||
$appLog = Join-Path $Root ".app.log"
|
||||
$appErr = Join-Path $Root ".app.err"
|
||||
Clear-LogFile $appLog
|
||||
Clear-LogFile $appErr
|
||||
Start-Process -FilePath "py" -ArgumentList "-3.13", "app.py" -WorkingDirectory $Root -WindowStyle Minimized `
|
||||
-RedirectStandardOutput $appLog -RedirectStandardError $appErr
|
||||
$deadline = (Get-Date).AddSeconds(120)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
if (Test-PortListening $p) { break }
|
||||
Start-Sleep -Seconds 3
|
||||
}
|
||||
if (-not (Test-PortListening $p)) {
|
||||
$errText = ""
|
||||
if (Test-Path $appErr) { $errText = Get-Content $appErr -Raw -ErrorAction SilentlyContinue }
|
||||
if ($errText -match "Uvicorn running") {
|
||||
Write-Host "Server process up — waiting for HTTP endpoints..."
|
||||
Start-Sleep -Seconds 10
|
||||
}
|
||||
}
|
||||
if (-not (Test-PortListening $p)) {
|
||||
if (Test-Path $appErr) {
|
||||
Write-Host "App startup log:" -ForegroundColor Red
|
||||
Get-Content $appErr -Tail 20 | ForEach-Object { Write-Host $_ }
|
||||
}
|
||||
throw "App did not become ready on http://127.0.0.1:$p"
|
||||
}
|
||||
Write-Host "App ready: http://127.0.0.1:$p"
|
||||
}
|
||||
|
||||
function Update-PublicUrl([string]$baseUrl) {
|
||||
$envFile = Join-Path $Root ".env"
|
||||
$content = Get-Content $envFile -Raw
|
||||
if ($content -match "PUBLIC_API_BASE_URL=.*") {
|
||||
$newContent = $content -replace "PUBLIC_API_BASE_URL=.*", "PUBLIC_API_BASE_URL=$baseUrl"
|
||||
} else {
|
||||
$newContent = $content.TrimEnd() + "`nPUBLIC_API_BASE_URL=$baseUrl`n"
|
||||
}
|
||||
Set-Content -Path $envFile -Value $newContent -NoNewline
|
||||
$env:PUBLIC_API_BASE_URL = $baseUrl
|
||||
Write-Host "Updated .env PUBLIC_API_BASE_URL=$baseUrl"
|
||||
}
|
||||
|
||||
function Ensure-ServerSecret {
|
||||
$envFile = Join-Path $Root ".env"
|
||||
if (-not (Test-Path $envFile)) { return }
|
||||
$lines = Get-Content $envFile
|
||||
$secret = $env:VOICE_SERVER_URL_SECRET
|
||||
foreach ($line in $lines) {
|
||||
if ($line -match '^\s*VOICE_SERVER_URL_SECRET=(.+)$') {
|
||||
$secret = $Matches[1].Trim()
|
||||
break
|
||||
}
|
||||
}
|
||||
if (-not $secret) {
|
||||
$secret = [guid]::NewGuid().ToString()
|
||||
$updated = $false
|
||||
$newLines = foreach ($line in $lines) {
|
||||
if ($line -match '^\s*VOICE_SERVER_URL_SECRET=') {
|
||||
$updated = $true
|
||||
"VOICE_SERVER_URL_SECRET=$secret"
|
||||
} else { $line }
|
||||
}
|
||||
if (-not $updated) { $newLines += "VOICE_SERVER_URL_SECRET=$secret" }
|
||||
Set-Content -Path $envFile -Value ($newLines -join "`n")
|
||||
Write-Host "Generated VOICE_SERVER_URL_SECRET in .env"
|
||||
}
|
||||
$env:VOICE_SERVER_URL_SECRET = $secret
|
||||
}
|
||||
|
||||
function Show-ProviderSetupSteps([string]$webhookUrl, [string]$secret) {
|
||||
Write-Host ""
|
||||
Write-Host "=== Provider webhook setup (Assistant -> Advanced -> Webhook Server) ===" -ForegroundColor Cyan
|
||||
Write-Host "Webhook URL:"
|
||||
Write-Host " $webhookUrl"
|
||||
Write-Host ""
|
||||
Write-Host "Secret header:"
|
||||
Write-Host " Name: X-Voice-Webhook-Secret"
|
||||
Write-Host " Value: $secret"
|
||||
Write-Host "================================================================" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
Load-DotEnv
|
||||
Ensure-ServerSecret
|
||||
$env:VOICE_AGENT_PORT = "$Port"
|
||||
|
||||
$needsRestart = $Restart.IsPresent
|
||||
if (Test-PortListening $Port) {
|
||||
if ($needsRestart) {
|
||||
Stop-PortProcess $Port
|
||||
} else {
|
||||
try {
|
||||
$cfg = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/api/voice/config" -TimeoutSec 3
|
||||
if (-not $cfg.webhook_url -and $env:PUBLIC_API_BASE_URL) {
|
||||
Write-Host "App running but missing webhook URL — restarting to reload .env"
|
||||
$needsRestart = $true
|
||||
Stop-PortProcess $Port
|
||||
} else {
|
||||
Write-Host "App already running on port $Port"
|
||||
}
|
||||
} catch {
|
||||
Write-Host "App already running on port $Port"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($needsRestart -or -not (Test-PortListening $Port)) {
|
||||
Start-VoiceApp $Port
|
||||
}
|
||||
|
||||
$public = $env:PUBLIC_API_BASE_URL
|
||||
try {
|
||||
$null = Invoke-WebRequest -Uri "$public/api/voice/config" -UseBasicParsing -TimeoutSec 5
|
||||
Write-Host "Tunnel OK: $public"
|
||||
} catch {
|
||||
Write-Host "Starting Cloudflare tunnel..."
|
||||
$log = Join-Path $Root ".tunnel.log"
|
||||
$logErr = "${log}.err"
|
||||
if (Test-Path $log) { Remove-Item $log -Force }
|
||||
if (Test-Path $logErr) { Remove-Item $logErr -Force }
|
||||
Start-Process -FilePath "cloudflared" -ArgumentList "tunnel", "--url", "http://127.0.0.1:$Port" `
|
||||
-RedirectStandardOutput $log -RedirectStandardError $logErr -WindowStyle Minimized
|
||||
$deadline = (Get-Date).AddSeconds(90)
|
||||
$newUrl = $null
|
||||
while ((Get-Date) -lt $deadline -and -not $newUrl) {
|
||||
Start-Sleep -Seconds 2
|
||||
foreach ($path in @($logErr, $log)) {
|
||||
if (-not (Test-Path $path)) { continue }
|
||||
$match = Select-String -Path $path -Pattern "https://[a-z0-9-]+\.trycloudflare\.com" | Select-Object -First 1
|
||||
if ($match) {
|
||||
$newUrl = $match.Matches[0].Value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (-not $newUrl) { throw "Could not read tunnel URL from $logErr" }
|
||||
Update-PublicUrl $newUrl
|
||||
Write-Host "Tunnel ready: $newUrl"
|
||||
if ($needsRestart -or -not (Test-PortListening $Port)) {
|
||||
Stop-PortProcess $Port
|
||||
Start-VoiceApp $Port
|
||||
}
|
||||
}
|
||||
|
||||
$config = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/api/voice/config"
|
||||
Write-Host "Voice configured: $($config.configured)"
|
||||
Write-Host "Webhook URL: $($config.webhook_url)"
|
||||
$webhook = if ($config.webhook_url) { $config.webhook_url } else { "$($env:PUBLIC_API_BASE_URL)/webhooks/voice" }
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Configuring voice provider via API..." -ForegroundColor Cyan
|
||||
py -3.13 setup_voice_provider.py
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "Voice provider auto-configured (tools + webhook URL + auth)." -ForegroundColor Green
|
||||
} else {
|
||||
Show-ProviderSetupSteps -webhookUrl $webhook -secret $env:VOICE_SERVER_URL_SECRET
|
||||
}
|
||||
|
||||
Write-Host "Done. Dashboard: http://127.0.0.1:$Port"
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Voice agent HTTP routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from integrations.voice.client import VoiceClient
|
||||
from integrations.voice.config import VoiceConfigError, load_voice_settings
|
||||
from integrations.voice.processor import process_voice_webhook
|
||||
from integrations.voice.store import VoiceCallStore
|
||||
from integrations.voice.verify import VerificationError, verify_webhook_request
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, PlainTextResponse, Response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Hold strong references to background tasks so the event loop (which only keeps
|
||||
# weak references) cannot garbage-collect them mid-flight and drop a webhook event.
|
||||
_background_tasks: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
async def api_voice_config_status(_request: Request) -> JSONResponse:
|
||||
try:
|
||||
settings = load_voice_settings()
|
||||
configured = True
|
||||
error = None
|
||||
except VoiceConfigError as exc:
|
||||
settings = None
|
||||
configured = False
|
||||
error = str(exc)
|
||||
return JSONResponse(
|
||||
{
|
||||
"configured": configured,
|
||||
"error": error,
|
||||
"webhook_url": settings.webhook_url if settings else None,
|
||||
"default_assistant_id": settings.default_assistant_id if settings else None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def api_voice_create_call(request: Request) -> JSONResponse:
|
||||
try:
|
||||
body = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
return JSONResponse({"error": "invalid JSON body"}, status_code=400)
|
||||
number = str(body.get("customer_number") or body.get("phone_number") or "").strip()
|
||||
if not number:
|
||||
return JSONResponse({"error": "customer_number is required"}, status_code=400)
|
||||
try:
|
||||
settings = load_voice_settings()
|
||||
client = VoiceClient(settings)
|
||||
result = await asyncio.to_thread(
|
||||
client.create_call,
|
||||
customer_number=number,
|
||||
assistant_id=body.get("assistant_id"),
|
||||
phone_number_id=body.get("phone_number_id"),
|
||||
metadata=body.get("metadata") if isinstance(body.get("metadata"), dict) else None,
|
||||
)
|
||||
except VoiceConfigError as exc:
|
||||
return JSONResponse({"error": str(exc)}, status_code=503)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return JSONResponse({"error": str(exc)}, status_code=502)
|
||||
call_id = result.get("id")
|
||||
if call_id:
|
||||
VoiceCallStore.upsert_call(str(call_id), status="scheduled", customer_number=number)
|
||||
return JSONResponse(result, status_code=201)
|
||||
|
||||
|
||||
async def api_voice_list_calls(_request: Request) -> JSONResponse:
|
||||
return JSONResponse({"calls": VoiceCallStore.list_calls()})
|
||||
|
||||
|
||||
async def api_voice_call_detail(request: Request) -> JSONResponse:
|
||||
call_id = request.path_params["call_id"]
|
||||
record = VoiceCallStore.get_call(call_id)
|
||||
if not record:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
return JSONResponse(record)
|
||||
|
||||
|
||||
async def webhook_voice(request: Request) -> Response:
|
||||
raw = (await request.body()).decode("utf-8")
|
||||
try:
|
||||
settings = load_voice_settings(require_key=False)
|
||||
except VoiceConfigError as exc:
|
||||
return JSONResponse({"error": str(exc)}, status_code=503)
|
||||
|
||||
headers = {k: v for k, v in request.headers.items()}
|
||||
try:
|
||||
verify_webhook_request(secret=settings.server_url_secret, headers=headers)
|
||||
except VerificationError:
|
||||
return JSONResponse({"error": "invalid secret"}, status_code=401)
|
||||
|
||||
try:
|
||||
payload = json.loads(raw) if raw else {}
|
||||
except json.JSONDecodeError:
|
||||
return JSONResponse({"error": "invalid JSON"}, status_code=400)
|
||||
|
||||
msg = payload.get("message") if isinstance(payload.get("message"), dict) else payload
|
||||
event_type = str((msg or {}).get("type") or payload.get("type") or "unknown")
|
||||
|
||||
if event_type in ("tool-calls", "assistant-request", "transfer-destination-request", "knowledge-base-request"):
|
||||
result = await asyncio.to_thread(process_voice_webhook, event_type, payload, settings=settings)
|
||||
return JSONResponse(result or {})
|
||||
|
||||
task = asyncio.create_task(
|
||||
asyncio.to_thread(process_voice_webhook, event_type, payload, settings=settings)
|
||||
)
|
||||
_background_tasks.add(task)
|
||||
task.add_done_callback(_background_tasks.discard)
|
||||
return PlainTextResponse("ok", status_code=200)
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "aiui"
|
||||
version = "0.3.141"
|
||||
version = "0.3.143"
|
||||
description = "YAML-driven website generator - CLI and compiler"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
@@ -11,9 +11,7 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<title>Documentation</title>
|
||||
<script>
|
||||
// Anti-flicker: apply dark mode class before first paint
|
||||
// Default to dark to prevent flash; React will correct if needed
|
||||
document.documentElement.classList.add('dark');
|
||||
(function(){try{var t=localStorage.getItem('aiui-theme-preference');var d=__AIUI_DARK_DEFAULT__;if(t==='dark'||(t==='system'&&matchMedia('(prefers-color-scheme: dark)').matches)||(!t&&d))document.documentElement.classList.add('dark');else if(t==='light')document.documentElement.classList.remove('dark');}catch(e){}})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="/assets/theme.css">
|
||||
</head>
|
||||
|
||||
Generated
+903
-2
File diff suppressed because it is too large.
Load diff
@@ -89,6 +89,7 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"lucide-react": "^0.563.0",
|
||||
"mermaid": "^11.17.2",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.5.4",
|
||||
|
||||
+249
-103
@@ -1,15 +1,18 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { applyTheme } from './themes'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { applyTheme, applyStoredThemePreference } from './themes'
|
||||
import './index.css'
|
||||
import type { UIConfig, DocsNav, RouteManifest, NavItem } from './types'
|
||||
import { Header } from './Header'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { Sidebar, MobileNavSheet } from './Sidebar'
|
||||
import { Content } from './Content'
|
||||
import { ZoneWidgets } from './Widgets'
|
||||
import { Toc } from './Toc'
|
||||
import { Footer } from './Footer'
|
||||
import { ChatLayout, AgentUILayout, CopilotWidget, PlaygroundLayout } from './layouts'
|
||||
import { LocaleProvider } from './i18n'
|
||||
import { resolveTemplate, shouldShowToc } from './resolver'
|
||||
import { normalizeDocPath } from './pathUtils'
|
||||
import { detectActiveTabIndex, filterNavByTab } from './docs/navTabs'
|
||||
|
||||
function SkipLink({ enabled }: { enabled?: boolean }) {
|
||||
if (!enabled) return null
|
||||
@@ -31,15 +34,30 @@ export default function App() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedItem, setSelectedItem] = useState<NavItem | null>(null)
|
||||
const [activeItemPath, setActiveItemPath] = useState<string>('')
|
||||
const [currentPath, setCurrentPath] = useState(() => normalizeDocPath(window.location.pathname))
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false)
|
||||
const [activeTabIndex, setActiveTabIndex] = useState(0)
|
||||
|
||||
const navTabs = config.navigation?.tabs ?? []
|
||||
const filteredNav = useMemo<DocsNav>(() => {
|
||||
if (!navTabs.length) return nav
|
||||
return { items: filterNavByTab(nav.items ?? [], navTabs[activeTabIndex]) }
|
||||
}, [nav, navTabs, activeTabIndex])
|
||||
|
||||
useEffect(() => {
|
||||
if (navTabs.length) {
|
||||
setActiveTabIndex(detectActiveTabIndex(navTabs, currentPath))
|
||||
}
|
||||
}, [currentPath, navTabs])
|
||||
|
||||
// Helper function to find nav item by path (searches top-level items and nested children)
|
||||
const findNavItemByPath = (navData: DocsNav, path: string): NavItem | null => {
|
||||
const normalizedPath = path.replace(/\/$/, '') || '/'
|
||||
const normalizedPath = normalizeDocPath(path)
|
||||
|
||||
const findItem = (items: NavItem[]): NavItem | null => {
|
||||
for (const item of items) {
|
||||
const itemPath = (item.path ?? '').replace(/\/$/, '')
|
||||
if (itemPath === normalizedPath || itemPath === normalizedPath.replace(/^\//, '')) {
|
||||
const itemPath = normalizeDocPath(item.path ?? '')
|
||||
if (itemPath === normalizedPath) {
|
||||
return item
|
||||
}
|
||||
if (item.children) {
|
||||
@@ -53,88 +71,187 @@ export default function App() {
|
||||
return navData.items ? findItem(navData.items) : null
|
||||
}
|
||||
|
||||
const updateSEO = (title: string, path: string, description?: string, configOverride?: UIConfig) => {
|
||||
const cfg = configOverride ?? config
|
||||
// Update title using SEO titleTemplate if available
|
||||
const seoOrigin = (cfg: UIConfig) => (cfg.seo?.siteUrl || window.location.origin).replace(/\/+$/, '')
|
||||
|
||||
const formatSeoTitle = (title: string, cfg: UIConfig) => {
|
||||
const titleTemplate = cfg.seo?.titleTemplate || '%s | %s'
|
||||
const siteName = cfg.site?.title || 'Documentation'
|
||||
if (titleTemplate.includes('%s')) {
|
||||
// Replace first %s with page title, second %s (if exists) with site name
|
||||
let formattedTitle = titleTemplate.replace('%s', title)
|
||||
if (formattedTitle.includes('%s')) {
|
||||
formattedTitle = formattedTitle.replace('%s', siteName)
|
||||
}
|
||||
document.title = formattedTitle
|
||||
} else {
|
||||
document.title = `${title} | ${siteName}`
|
||||
return formattedTitle
|
||||
}
|
||||
return `${title} | ${siteName}`
|
||||
}
|
||||
|
||||
const updateSEO = (
|
||||
title: string,
|
||||
path: string,
|
||||
description?: string,
|
||||
configOverride?: UIConfig,
|
||||
noindex?: boolean,
|
||||
) => {
|
||||
const cfg = configOverride ?? config
|
||||
const canonicalPath = normalizeDocPath(path)
|
||||
const formattedTitle = formatSeoTitle(title, cfg)
|
||||
const metaDescription = description || `${title} - ${cfg.site?.description || ''}`
|
||||
const absoluteUrl = `${seoOrigin(cfg)}${canonicalPath}`
|
||||
|
||||
document.title = formattedTitle
|
||||
|
||||
// Update canonical URL
|
||||
let canonical = document.querySelector('link[rel="canonical"]') as HTMLLinkElement
|
||||
if (!canonical) {
|
||||
canonical = document.createElement('link')
|
||||
canonical.rel = 'canonical'
|
||||
document.head.appendChild(canonical)
|
||||
}
|
||||
canonical.href = window.location.origin + path
|
||||
canonical.href = absoluteUrl
|
||||
|
||||
// Update meta description
|
||||
let metaDesc = document.querySelector('meta[name="description"]') as HTMLMetaElement
|
||||
if (!metaDesc) {
|
||||
metaDesc = document.createElement('meta')
|
||||
metaDesc.name = 'description'
|
||||
document.head.appendChild(metaDesc)
|
||||
}
|
||||
metaDesc.content = description || `${title} - ${cfg.site?.description || ''}`
|
||||
metaDesc.content = metaDescription
|
||||
|
||||
// Update Open Graph tags
|
||||
let ogTitle = document.querySelector('meta[property="og:title"]') as HTMLMetaElement
|
||||
if (!ogTitle) {
|
||||
ogTitle = document.createElement('meta')
|
||||
ogTitle.setAttribute('property', 'og:title')
|
||||
document.head.appendChild(ogTitle)
|
||||
}
|
||||
ogTitle.content = title
|
||||
|
||||
let ogUrl = document.querySelector('meta[property="og:url"]') as HTMLMetaElement
|
||||
if (!ogUrl) {
|
||||
ogUrl = document.createElement('meta')
|
||||
ogUrl.setAttribute('property', 'og:url')
|
||||
document.head.appendChild(ogUrl)
|
||||
}
|
||||
ogUrl.content = window.location.origin + path
|
||||
|
||||
// Update Twitter meta tags if configured
|
||||
if (cfg.seo?.twitter) {
|
||||
Object.entries(cfg.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 (cfg.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)
|
||||
const ensureMeta = (selector: string, create: () => HTMLMetaElement) => {
|
||||
let el = document.querySelector(selector) as HTMLMetaElement | null
|
||||
if (!el) {
|
||||
el = create()
|
||||
document.head.appendChild(el)
|
||||
}
|
||||
ogImage.content = cfg.seo.defaultImage
|
||||
return el
|
||||
}
|
||||
|
||||
ensureMeta('meta[name="robots"]', () => {
|
||||
const el = document.createElement('meta')
|
||||
el.name = 'robots'
|
||||
return el
|
||||
}).content = noindex ? 'noindex, nofollow' : 'index, follow'
|
||||
|
||||
ensureMeta('meta[property="og:title"]', () => {
|
||||
const el = document.createElement('meta')
|
||||
el.setAttribute('property', 'og:title')
|
||||
return el
|
||||
}).content = formattedTitle
|
||||
|
||||
ensureMeta('meta[property="og:description"]', () => {
|
||||
const el = document.createElement('meta')
|
||||
el.setAttribute('property', 'og:description')
|
||||
return el
|
||||
}).content = metaDescription
|
||||
|
||||
ensureMeta('meta[property="og:url"]', () => {
|
||||
const el = document.createElement('meta')
|
||||
el.setAttribute('property', 'og:url')
|
||||
return el
|
||||
}).content = absoluteUrl
|
||||
|
||||
ensureMeta('meta[property="og:type"]', () => {
|
||||
const el = document.createElement('meta')
|
||||
el.setAttribute('property', 'og:type')
|
||||
return el
|
||||
}).content = 'article'
|
||||
|
||||
ensureMeta('meta[name="twitter:card"]', () => {
|
||||
const el = document.createElement('meta')
|
||||
el.name = 'twitter:card'
|
||||
return el
|
||||
}).content = 'summary'
|
||||
|
||||
ensureMeta('meta[name="twitter:title"]', () => {
|
||||
const el = document.createElement('meta')
|
||||
el.name = 'twitter:title'
|
||||
return el
|
||||
}).content = formattedTitle
|
||||
|
||||
ensureMeta('meta[name="twitter:description"]', () => {
|
||||
const el = document.createElement('meta')
|
||||
el.name = 'twitter:description'
|
||||
return el
|
||||
}).content = metaDescription
|
||||
|
||||
if (cfg.seo?.twitter?.handle) {
|
||||
ensureMeta('meta[name="twitter:site"]', () => {
|
||||
const el = document.createElement('meta')
|
||||
el.name = 'twitter:site'
|
||||
return el
|
||||
}).content = cfg.seo.twitter.handle
|
||||
}
|
||||
|
||||
if (cfg.seo?.defaultImage) {
|
||||
const image = cfg.seo.defaultImage.startsWith('http')
|
||||
? cfg.seo.defaultImage
|
||||
: `${seoOrigin(cfg)}${cfg.seo.defaultImage}`
|
||||
ensureMeta('meta[property="og:image"]', () => {
|
||||
const el = document.createElement('meta')
|
||||
el.setAttribute('property', 'og:image')
|
||||
return el
|
||||
}).content = image
|
||||
ensureMeta('meta[name="twitter:image"]', () => {
|
||||
const el = document.createElement('meta')
|
||||
el.name = 'twitter:image'
|
||||
return el
|
||||
}).content = image
|
||||
}
|
||||
|
||||
let jsonLd = document.querySelector('script#aiui-jsonld') as HTMLScriptElement | null
|
||||
if (!jsonLd) {
|
||||
jsonLd = document.createElement('script')
|
||||
jsonLd.type = 'application/ld+json'
|
||||
jsonLd.id = 'aiui-jsonld'
|
||||
document.head.appendChild(jsonLd)
|
||||
}
|
||||
jsonLd.textContent = JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'TechArticle',
|
||||
headline: title,
|
||||
description: metaDescription,
|
||||
url: absoluteUrl,
|
||||
isPartOf: {
|
||||
'@type': 'WebSite',
|
||||
name: cfg.site?.title || 'Documentation',
|
||||
url: `${seoOrigin(cfg)}/`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const selectNavItem = (item: NavItem) => {
|
||||
const path = normalizeDocPath(item.path || '/')
|
||||
setSelectedItem(item)
|
||||
setActiveItemPath(item.path || item.title)
|
||||
updateSEO(item.title, item.path || '/')
|
||||
updateSEO(item.title, path, item.description, undefined, item.noindex)
|
||||
}
|
||||
|
||||
// Keep React nav state in sync when plugins perform SPA navigation
|
||||
useEffect(() => {
|
||||
const onPluginNavigate = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ path?: string }>).detail
|
||||
const path = normalizeDocPath(detail?.path ?? window.location.pathname)
|
||||
setCurrentPath(path)
|
||||
setMobileNavOpen(false)
|
||||
|
||||
const found = findNavItemByPath(nav, path)
|
||||
if (found) {
|
||||
selectNavItem(found)
|
||||
return
|
||||
}
|
||||
|
||||
if (path === '/') {
|
||||
const docsHome = findNavItemByPath(nav, '/docs/index')
|
||||
if (docsHome) {
|
||||
selectNavItem(docsHome)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('aiui:navigate', onPluginNavigate)
|
||||
return () => window.removeEventListener('aiui:navigate', onPluginNavigate)
|
||||
}, [nav, config])
|
||||
|
||||
useEffect(() => {
|
||||
async function loadManifests() {
|
||||
try {
|
||||
@@ -156,27 +273,33 @@ export default function App() {
|
||||
|
||||
// Apply theme from YAML config
|
||||
const theme = configData.site?.theme
|
||||
const yamlDark = theme?.darkMode !== false
|
||||
applyTheme(
|
||||
theme?.preset || 'zinc',
|
||||
theme?.darkMode !== false,
|
||||
yamlDark,
|
||||
theme?.radius || 'md'
|
||||
)
|
||||
applyStoredThemePreference(yamlDark)
|
||||
|
||||
// Handle initial URL path for SPA routing
|
||||
const currentPath = window.location.pathname.replace(/\/$/, '') || '/'
|
||||
if (currentPath !== '/') {
|
||||
const found = findNavItemByPath(navData, currentPath)
|
||||
const initialPath = normalizeDocPath(window.location.pathname)
|
||||
setCurrentPath(initialPath)
|
||||
if (initialPath !== window.location.pathname) {
|
||||
window.history.replaceState(null, '', initialPath + window.location.search + window.location.hash)
|
||||
}
|
||||
if (initialPath !== '/') {
|
||||
const found = findNavItemByPath(navData, initialPath)
|
||||
if (found) {
|
||||
setSelectedItem(found)
|
||||
setActiveItemPath(found.path || found.title)
|
||||
updateSEO(found.title, found.path || currentPath, undefined, configData)
|
||||
updateSEO(found.title, initialPath, found.description, configData, found.noindex)
|
||||
}
|
||||
} else {
|
||||
const docsHome = findNavItemByPath(navData, '/docs/index')
|
||||
if (docsHome) {
|
||||
setSelectedItem(docsHome)
|
||||
setActiveItemPath(docsHome.path || docsHome.title)
|
||||
updateSEO(docsHome.title, docsHome.path || '/docs/index', undefined, configData)
|
||||
updateSEO(docsHome.title, normalizeDocPath(docsHome.path || '/docs/index'), docsHome.description, configData, docsHome.noindex)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -192,7 +315,8 @@ export default function App() {
|
||||
// Handle browser back/forward navigation in a separate effect
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
const path = window.location.pathname.replace(/\/$/, '') || '/'
|
||||
const path = normalizeDocPath(window.location.pathname)
|
||||
setCurrentPath(path)
|
||||
const found = findNavItemByPath(nav, path)
|
||||
|
||||
if (found) {
|
||||
@@ -214,18 +338,47 @@ export default function App() {
|
||||
}, [nav, config])
|
||||
|
||||
const handleItemClick = (item: NavItem) => {
|
||||
setSelectedItem(item)
|
||||
setActiveItemPath(item.path || item.title)
|
||||
// Use History API for SEO-friendly URLs
|
||||
const path = item.path || `/${item.title.toLowerCase().replace(/\s+/g, '-')}`
|
||||
const path = normalizeDocPath(item.path || `/${item.title.toLowerCase().replace(/\s+/g, '-')}`)
|
||||
window.history.pushState({ path }, item.title, path)
|
||||
// Update SEO meta tags
|
||||
updateSEO(item.title, path)
|
||||
window.dispatchEvent(new CustomEvent('aiui:navigate', { detail: { path } }))
|
||||
}
|
||||
|
||||
const templateMatch = resolveTemplate(
|
||||
currentPath.replace(/^\//, ''),
|
||||
routes,
|
||||
config.templates ?? {},
|
||||
)
|
||||
const activeTemplateKey = templateMatch?.template ?? 'docs'
|
||||
const activeTemplate = config.templates?.[activeTemplateKey] ?? config.templates?.docs
|
||||
const layout = templateMatch?.layout ?? activeTemplate?.layout ?? 'ThreeColumnLayout'
|
||||
const zones = activeTemplate?.zones
|
||||
const showToc = shouldShowToc(templateMatch)
|
||||
const showGlobalFooter = true
|
||||
|
||||
const docsChrome = (
|
||||
<>
|
||||
<MobileNavSheet
|
||||
open={mobileNavOpen}
|
||||
onOpenChange={setMobileNavOpen}
|
||||
nav={filteredNav}
|
||||
activeItem={activeItemPath}
|
||||
onItemClick={handleItemClick}
|
||||
title={config.site?.title || 'Navigation'}
|
||||
/>
|
||||
<Header
|
||||
config={config}
|
||||
templateKey={activeTemplateKey}
|
||||
onMenuClick={() => setMobileNavOpen(true)}
|
||||
navTabs={navTabs}
|
||||
activeTabIndex={activeTabIndex}
|
||||
onTabChange={setActiveTabIndex}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="dark flex items-center justify-center min-h-screen bg-background">
|
||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||
<span className="text-muted-foreground">Loading...</span>
|
||||
</div>
|
||||
)
|
||||
@@ -233,53 +386,49 @@ export default function App() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="dark flex flex-col items-center justify-center min-h-screen gap-4 bg-background">
|
||||
<div className="flex flex-col items-center justify-center min-h-screen gap-4 bg-background">
|
||||
<h2 className="text-xl font-semibold text-destructive">Failed to load</h2>
|
||||
<p className="text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Determine layout from templates config
|
||||
const layout = config.templates?.docs?.layout || 'ThreeColumnLayout'
|
||||
const zones = config.templates?.docs?.zones
|
||||
|
||||
// Render based on layout type
|
||||
const renderLayout = () => {
|
||||
switch (layout) {
|
||||
case 'TwoColumnLayout':
|
||||
// Sidebar + Content (no TOC)
|
||||
return (
|
||||
<div className="flex">
|
||||
<Sidebar nav={nav} activeItem={activeItemPath} onItemClick={handleItemClick} />
|
||||
<Content config={config} routes={routes} selectedItem={selectedItem} />
|
||||
<Content config={config} routes={routes} selectedItem={selectedItem} currentPath={currentPath} />
|
||||
</div>
|
||||
)
|
||||
case 'CenteredLayout':
|
||||
// Centered content, no sidebar
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<div className="w-full max-w-4xl px-6">
|
||||
{zones?.hero && zones.hero.length > 0 && <ZoneWidgets widgets={zones.hero} />}
|
||||
<Content config={config} routes={routes} selectedItem={selectedItem} />
|
||||
<Content config={config} routes={routes} selectedItem={selectedItem} currentPath={currentPath} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
case 'FullWidthLayout':
|
||||
// Full width content
|
||||
return (
|
||||
<div className="px-6">
|
||||
{zones?.hero && zones.hero.length > 0 && <ZoneWidgets widgets={zones.hero} />}
|
||||
<Content config={config} routes={routes} selectedItem={selectedItem} />
|
||||
<div className="flex">
|
||||
{(nav?.items?.length ?? 0) > 0 && (
|
||||
<Sidebar nav={nav} activeItem={activeItemPath} onItemClick={handleItemClick} />
|
||||
)}
|
||||
<div className="flex-1 px-6">
|
||||
{zones?.hero && zones.hero.length > 0 && <ZoneWidgets widgets={zones.hero} />}
|
||||
<Content config={config} routes={routes} selectedItem={selectedItem} currentPath={currentPath} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
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 && zones.hero.length > 0 && <ZoneWidgets widgets={zones.hero} />}
|
||||
@@ -294,9 +443,9 @@ export default function App() {
|
||||
</aside>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<Content config={config} routes={routes} selectedItem={selectedItem} />
|
||||
<Content config={config} routes={routes} selectedItem={selectedItem} currentPath={currentPath} />
|
||||
</div>
|
||||
<Toc selectedItem={selectedItem} zones={zones} />
|
||||
<Toc selectedItem={selectedItem} zones={zones} showToc={showToc} />
|
||||
</div>
|
||||
{zones?.bottomNav && zones.bottomNav.length > 0 && (
|
||||
<div className="border-t p-4 bg-muted/30">
|
||||
@@ -313,12 +462,11 @@ export default function App() {
|
||||
}
|
||||
case 'ThreeColumnLayout':
|
||||
default:
|
||||
// Classic: Sidebar + Content + TOC with zones
|
||||
return (
|
||||
<div className="flex">
|
||||
<Sidebar nav={nav} activeItem={activeItemPath} onItemClick={handleItemClick} />
|
||||
<Content config={config} routes={routes} selectedItem={selectedItem} />
|
||||
<Toc selectedItem={selectedItem} zones={zones} />
|
||||
<div className="docs-shell flex w-full min-h-[calc(100vh-3.5rem)]">
|
||||
<Sidebar nav={filteredNav} activeItem={activeItemPath} onItemClick={handleItemClick} />
|
||||
<Content config={config} routes={routes} selectedItem={selectedItem} currentPath={currentPath} />
|
||||
{showToc && <Toc selectedItem={selectedItem} zones={zones} showToc={showToc} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -356,14 +504,13 @@ export default function App() {
|
||||
// Dashboard is handled entirely by plugin dashboard.js — React yields
|
||||
return null
|
||||
case 'custom':
|
||||
// For custom, show docs with copilot widget if chat enabled
|
||||
if (config.chat?.enabled) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<SkipLink enabled={config.a11y?.skipToContent} />
|
||||
<Header config={config} />
|
||||
{docsChrome}
|
||||
{renderLayout()}
|
||||
<Footer config={config} />
|
||||
{showGlobalFooter && <Footer config={config} templateKey={activeTemplateKey} />}
|
||||
<CopilotWidget config={config.chat} layout={config.layout} />
|
||||
</div>
|
||||
)
|
||||
@@ -371,23 +518,22 @@ export default function App() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<SkipLink enabled={config.a11y?.skipToContent} />
|
||||
<Header config={config} />
|
||||
{docsChrome}
|
||||
{renderLayout()}
|
||||
<Footer config={config} />
|
||||
{showGlobalFooter && <Footer config={config} templateKey={activeTemplateKey} />}
|
||||
</div>
|
||||
)
|
||||
case 'docs':
|
||||
default:
|
||||
// Docs mode - optionally with copilot widget
|
||||
if (config.chat?.enabled) {
|
||||
const layoutMode = config.layout?.mode
|
||||
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} />
|
||||
{docsChrome}
|
||||
{renderLayout()}
|
||||
<Footer config={config} />
|
||||
{showGlobalFooter && <Footer config={config} templateKey={activeTemplateKey} />}
|
||||
<CopilotWidget config={config.chat} layout={config.layout} />
|
||||
</div>
|
||||
)
|
||||
@@ -396,9 +542,9 @@ export default function App() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<SkipLink enabled={config.a11y?.skipToContent} />
|
||||
<Header config={config} />
|
||||
{docsChrome}
|
||||
{renderLayout()}
|
||||
<Footer config={config} />
|
||||
{showGlobalFooter && <Footer config={config} templateKey={activeTemplateKey} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+122
-46
@@ -1,116 +1,191 @@
|
||||
// Content component — markdown rendering and landing page
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Children, isValidElement, useEffect, useRef, useState } from 'react'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { SHADCN_THEMES } from './themes'
|
||||
import { enhanceMkdocsDom } from './markdown/mkdocsEnhance'
|
||||
import { preprocessMkdocsMarkdown } from './markdown/mkdocsPreprocess'
|
||||
import { MermaidDiagram } from './markdown/MermaidDiagram'
|
||||
import { slugify } from './markdown/slug'
|
||||
import { MobileToc } from './Toc'
|
||||
import { docPathToMarkdown, isInternalDocHref, normalizeDocHref, normalizeDocPath } from './pathUtils'
|
||||
import type { UIConfig, NavItem, RouteManifest } from './types'
|
||||
|
||||
interface ContentProps {
|
||||
config: UIConfig
|
||||
routes: RouteManifest
|
||||
selectedItem: NavItem | null
|
||||
currentPath: string
|
||||
}
|
||||
|
||||
export function Content({ config, routes, selectedItem }: ContentProps) {
|
||||
export function Content({ config, routes, selectedItem, currentPath }: ContentProps) {
|
||||
const [markdown, setMarkdown] = useState<string>('')
|
||||
const [loadedPath, setLoadedPath] = useState('')
|
||||
const [loadingContent, setLoadingContent] = useState(false)
|
||||
const articleRef = useRef<HTMLElement>(null)
|
||||
const theme = config.site?.theme
|
||||
|
||||
// Load markdown content when selected item changes
|
||||
// Load markdown from URL path (source of truth) to avoid stale selectedItem races
|
||||
useEffect(() => {
|
||||
if (!selectedItem?.path) {
|
||||
setMarkdown('')
|
||||
const urlPath = normalizeDocPath(currentPath)
|
||||
if (urlPath === '/') {
|
||||
setLoadingContent(false)
|
||||
setLoadedPath('')
|
||||
if (!selectedItem?.path) {
|
||||
setMarkdown('')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const mdUrl = docPathToMarkdown(urlPath)
|
||||
const pageTitle = urlPath.split('/').pop()?.replace(/-/g, ' ') ?? 'page'
|
||||
const controller = new AbortController()
|
||||
|
||||
setMarkdown('')
|
||||
setLoadedPath('')
|
||||
setLoadingContent(true)
|
||||
|
||||
const loadContent = async () => {
|
||||
setLoadingContent(true)
|
||||
try {
|
||||
const docPath = (selectedItem.path ?? '').replace(/^\//, '') + '.md'
|
||||
const response = await fetch(`/${docPath}`)
|
||||
const response = await fetch(mdUrl, { signal: controller.signal })
|
||||
if (controller.signal.aborted) return
|
||||
if (response.ok) {
|
||||
const content = await response.text()
|
||||
setMarkdown(preprocessMkdocsMarkdown(content))
|
||||
setLoadedPath(urlPath)
|
||||
window.dispatchEvent(new CustomEvent('aiui:content-loaded', {
|
||||
detail: { path: urlPath },
|
||||
}))
|
||||
} else {
|
||||
setMarkdown(`*Content for **${selectedItem.title}** not found.*`)
|
||||
setMarkdown(`*Content for **${pageTitle}** not found.*`)
|
||||
setLoadedPath(urlPath)
|
||||
}
|
||||
} catch {
|
||||
setMarkdown(`*Failed to load content for **${selectedItem.title}**.*`)
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return
|
||||
setMarkdown(`*Failed to load content for **${pageTitle}**.*`)
|
||||
setLoadedPath(urlPath)
|
||||
} finally {
|
||||
setLoadingContent(false)
|
||||
if (!controller.signal.aborted) {
|
||||
setLoadingContent(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadContent()
|
||||
}, [selectedItem])
|
||||
return () => {
|
||||
controller.abort()
|
||||
setLoadingContent(false)
|
||||
}
|
||||
}, [currentPath, selectedItem?.path])
|
||||
|
||||
useEffect(() => {
|
||||
if (!markdown || !articleRef.current) return
|
||||
enhanceMkdocsDom(articleRef.current)
|
||||
const article = articleRef.current
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
enhanceMkdocsDom(article)
|
||||
})
|
||||
return () => window.cancelAnimationFrame(frame)
|
||||
}, [markdown])
|
||||
|
||||
const headingId = (children?: React.ReactNode) => slugify(String(children ?? ''))
|
||||
|
||||
const mermaidChartFromPre = (children?: React.ReactNode): string | null => {
|
||||
try {
|
||||
const child = Children.only(children)
|
||||
if (!isValidElement<{ className?: string; children?: React.ReactNode }>(child)) return null
|
||||
const className = child.props.className ?? ''
|
||||
if (!className.includes('language-mermaid')) return null
|
||||
return String(child.props.children ?? '').replace(/\n$/, '').trim()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const markdownComponents = {
|
||||
h1: ({ children }: { children?: React.ReactNode }) => <h1 className="text-3xl font-bold mt-8 mb-4">{children}</h1>,
|
||||
h2: ({ children }: { children?: React.ReactNode }) => <h2 className="text-2xl font-semibold mt-8 mb-4 text-primary">{children}</h2>,
|
||||
h3: ({ children }: { children?: React.ReactNode }) => <h3 className="text-xl font-semibold mt-6 mb-3">{children}</h3>,
|
||||
h4: ({ children }: { children?: React.ReactNode }) => <h4 className="text-lg font-semibold mt-4 mb-2">{children}</h4>,
|
||||
p: ({ children }: { children?: React.ReactNode }) => <p className="my-3 text-muted-foreground leading-relaxed">{children}</p>,
|
||||
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => <a href={href} className="text-primary hover:underline">{children}</a>,
|
||||
h1: ({ children }: { children?: React.ReactNode }) => <h1 id={headingId(children)} className="text-3xl font-bold mt-8 mb-4 scroll-mt-20">{children}</h1>,
|
||||
h2: ({ children }: { children?: React.ReactNode }) => <h2 id={headingId(children)} className="text-2xl font-semibold mt-8 mb-4 text-primary scroll-mt-20">{children}</h2>,
|
||||
h3: ({ children }: { children?: React.ReactNode }) => <h3 id={headingId(children)} className="text-xl font-semibold mt-6 mb-3 scroll-mt-20">{children}</h3>,
|
||||
h4: ({ children }: { children?: React.ReactNode }) => <h4 id={headingId(children)} className="text-lg font-semibold mt-4 mb-2 scroll-mt-20">{children}</h4>,
|
||||
p: ({ children }: { children?: React.ReactNode }) => <p className="my-3 text-foreground leading-relaxed">{children}</p>,
|
||||
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => {
|
||||
const seoHref = href && isInternalDocHref(href) ? normalizeDocHref(href) : href
|
||||
return (
|
||||
<a href={seoHref} className="text-primary hover:underline">
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
},
|
||||
ul: ({ children }: { children?: React.ReactNode }) => <ul className="list-disc pl-6 my-4 space-y-1">{children}</ul>,
|
||||
ol: ({ children }: { children?: React.ReactNode }) => <ol className="list-decimal pl-6 my-4 space-y-1">{children}</ol>,
|
||||
li: ({ children }: { children?: React.ReactNode }) => <li className="text-muted-foreground">{children}</li>,
|
||||
blockquote: ({ children }: { children?: React.ReactNode }) => <blockquote className="border-l-4 border-primary pl-4 my-4 italic text-muted-foreground">{children}</blockquote>,
|
||||
li: ({ children }: { children?: React.ReactNode }) => <li className="text-foreground">{children}</li>,
|
||||
blockquote: ({ children }: { children?: React.ReactNode }) => <blockquote className="border-l-4 border-primary pl-4 my-4 italic text-foreground/80">{children}</blockquote>,
|
||||
code: ({ className, children }: { className?: string; children?: React.ReactNode }) => {
|
||||
if (className?.includes('language-mermaid')) {
|
||||
return <MermaidDiagram chart={String(children ?? '').replace(/\n$/, '').trim()} />
|
||||
}
|
||||
const isInline = !className && String(children).indexOf('\n') === -1
|
||||
if (isInline) {
|
||||
return <code className="bg-primary/10 text-primary px-1.5 py-0.5 rounded text-sm font-mono">{children}</code>
|
||||
}
|
||||
return <code className={`block font-mono text-foreground ${className ?? ''}`}>{children}</code>
|
||||
},
|
||||
pre: ({ children }: { children?: React.ReactNode }) => (
|
||||
<pre className="bg-muted text-foreground border border-border p-4 rounded-lg text-sm overflow-auto my-4 font-mono whitespace-pre">{children}</pre>
|
||||
),
|
||||
pre: ({ children }: { children?: React.ReactNode }) => {
|
||||
if (isValidElement(children) && children.type === MermaidDiagram) {
|
||||
return children
|
||||
}
|
||||
const chart = mermaidChartFromPre(children)
|
||||
if (chart) return <MermaidDiagram chart={chart} />
|
||||
return (
|
||||
<pre className="bg-muted text-foreground border border-border p-4 rounded-lg text-sm overflow-auto my-4 font-mono whitespace-pre">{children}</pre>
|
||||
)
|
||||
},
|
||||
table: ({ children }: { children?: React.ReactNode }) => <div className="overflow-auto my-4"><table className="w-full border-collapse text-sm">{children}</table></div>,
|
||||
thead: ({ children }: { children?: React.ReactNode }) => <thead className="bg-muted/50">{children}</thead>,
|
||||
tr: ({ children }: { children?: React.ReactNode }) => <tr className="border-b">{children}</tr>,
|
||||
th: ({ children }: { children?: React.ReactNode }) => <th className="px-4 py-2 text-left font-medium">{children}</th>,
|
||||
td: ({ children }: { children?: React.ReactNode }) => <td className="px-4 py-2 text-muted-foreground">{children}</td>,
|
||||
td: ({ children }: { children?: React.ReactNode }) => <td className="px-4 py-2 text-foreground">{children}</td>,
|
||||
hr: () => <hr className="my-6 border-border" />,
|
||||
strong: ({ children }: { children?: React.ReactNode }) => <strong className="font-semibold text-foreground">{children}</strong>,
|
||||
em: ({ children }: { children?: React.ReactNode }) => <em>{children}</em>,
|
||||
}
|
||||
|
||||
const contentReady = loadedPath === normalizeDocPath(currentPath) && markdown.length > 0
|
||||
|
||||
if (selectedItem) {
|
||||
return (
|
||||
<main id="main-content" className="flex-1 p-8 max-w-3xl">
|
||||
{loadingContent ? (
|
||||
<div className="text-muted-foreground">Loading content...</div>
|
||||
) : markdown ? (
|
||||
<article
|
||||
ref={articleRef}
|
||||
className="prose prose-neutral dark:prose-invert max-w-none prose-pre:bg-muted prose-pre:text-foreground prose-code:text-foreground"
|
||||
>
|
||||
<Markdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{markdown}
|
||||
</Markdown>
|
||||
</article>
|
||||
) : (
|
||||
<div className="bg-muted/50 border rounded-lg p-6">
|
||||
<p className="text-muted-foreground">
|
||||
Content for <strong className="text-primary">{selectedItem.title}</strong> would be displayed here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<main id="main-content" className="docs-main flex-1 min-w-0">
|
||||
<div className="docs-main-inner mx-auto w-full max-w-[720px] px-6 py-8 lg:px-8">
|
||||
<MobileToc selectedItem={selectedItem} />
|
||||
{loadingContent && (
|
||||
<div className="text-muted-foreground animate-pulse">Loading content...</div>
|
||||
)}
|
||||
{contentReady && (
|
||||
<article
|
||||
key={currentPath}
|
||||
ref={articleRef}
|
||||
className="prose prose-neutral dark:prose-invert max-w-none prose-pre:bg-muted prose-pre:text-foreground prose-code:text-foreground"
|
||||
>
|
||||
<Markdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{markdown}
|
||||
</Markdown>
|
||||
</article>
|
||||
)}
|
||||
{!loadingContent && !contentReady && loadedPath === normalizeDocPath(currentPath) && (
|
||||
<div className="bg-muted/50 border rounded-lg p-6">
|
||||
<p className="text-muted-foreground">
|
||||
Content for <strong className="text-primary">{selectedItem.title}</strong> would be displayed here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-8 max-w-3xl">
|
||||
<main id="main-content" className="docs-main flex-1 min-w-0">
|
||||
<div className="docs-main-inner mx-auto w-full max-w-[720px] px-6 py-8 lg:px-8">
|
||||
<h1 className="text-4xl font-bold tracking-tight mb-4">
|
||||
{config.site?.title || 'Documentation'}
|
||||
</h1>
|
||||
@@ -160,6 +235,7 @@ export function Content({ config, routes, selectedItem }: ContentProps) {
|
||||
<pre className="bg-muted text-foreground border border-border p-4 rounded-lg text-sm overflow-auto">
|
||||
{JSON.stringify(routes.routes?.slice(0, 3), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
// Footer component
|
||||
import type { UIConfig } from './types'
|
||||
|
||||
export function Footer({ config }: { config: UIConfig }) {
|
||||
// Resolve footer component via template slot ref (e.g., footer_main)
|
||||
const footerSlot = config.templates?.docs?.slots?.footer
|
||||
export function Footer({ config, templateKey = 'docs' }: { config: UIConfig; templateKey?: string }) {
|
||||
const footerSlot = config.templates?.[templateKey]?.slots?.footer
|
||||
const footerRef = footerSlot?.ref
|
||||
const footerComponent = footerRef
|
||||
? config.components?.[footerRef]
|
||||
|
||||
+130
-30
@@ -1,10 +1,28 @@
|
||||
// Header component
|
||||
import type { UIConfig } from './types'
|
||||
// Header component — Mintlify-style docs chrome
|
||||
import { Github, Menu, Search } from 'lucide-react'
|
||||
import type { NavTabConfig, UIConfig } from './types'
|
||||
import { LocaleSwitcher } from './i18n'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ThemeToggle } from '@/components/theme-toggle'
|
||||
|
||||
export function Header({ config }: { config: UIConfig }) {
|
||||
// Resolve header component via template slot ref (e.g., header_main)
|
||||
const headerSlot = config.templates?.docs?.slots?.header
|
||||
interface HeaderProps {
|
||||
config: UIConfig
|
||||
templateKey?: string
|
||||
onMenuClick?: () => void
|
||||
navTabs?: NavTabConfig[]
|
||||
activeTabIndex?: number
|
||||
onTabChange?: (index: number) => void
|
||||
}
|
||||
|
||||
export function Header({
|
||||
config,
|
||||
templateKey = 'docs',
|
||||
onMenuClick,
|
||||
navTabs,
|
||||
activeTabIndex = 0,
|
||||
onTabChange,
|
||||
}: HeaderProps) {
|
||||
const headerSlot = config.templates?.[templateKey]?.slots?.header
|
||||
const headerRef = headerSlot?.ref
|
||||
const headerComponent = headerRef
|
||||
? config.components?.[headerRef]
|
||||
@@ -16,56 +34,138 @@ export function Header({ config }: { config: UIConfig }) {
|
||||
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 || '/'
|
||||
const logoText = header?.logoText || config.site?.title || 'PraisonAIUI'
|
||||
|
||||
const tabs = navTabs ?? config.navigation?.tabs ?? []
|
||||
const navbar = config.navbar
|
||||
const navbarLinks = navbar?.links ?? header?.links ?? []
|
||||
const primary = navbar?.primary ?? (header?.cta ? { type: 'button' as const, label: header.cta.label, href: header.cta.href } : undefined)
|
||||
const showMintlifyNav = tabs.length > 0
|
||||
|
||||
const onLogoClick = (event: React.MouseEvent) => {
|
||||
event.preventDefault()
|
||||
window.history.pushState({}, '', logoHref)
|
||||
window.dispatchEvent(new CustomEvent('aiui:navigate', { detail: { path: logoHref } }))
|
||||
}
|
||||
|
||||
const onSearchClick = () => {
|
||||
window.dispatchEvent(new CustomEvent('aiui:search-open'))
|
||||
}
|
||||
|
||||
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={logoHref} className="flex items-center gap-3 group cursor-pointer" onClick={(e) => {
|
||||
e.preventDefault()
|
||||
window.history.pushState({}, '', logoHref)
|
||||
window.location.reload()
|
||||
}}>
|
||||
<header
|
||||
className="sticky top-0 z-50 border-b border-border/40 bg-background/90 backdrop-blur-xl supports-[backdrop-filter]:bg-background/75"
|
||||
data-aiui-header-tabs={showMintlifyNav ? 'true' : undefined}
|
||||
>
|
||||
<div className="flex h-14 items-center gap-4 px-4 lg:px-6">
|
||||
{onMenuClick && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="md:hidden shrink-0 -ml-1"
|
||||
onClick={onMenuClick}
|
||||
aria-label="Open navigation menu"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={logoHref}
|
||||
className="flex items-center gap-2.5 group cursor-pointer shrink-0 min-w-0"
|
||||
onClick={onLogoClick}
|
||||
>
|
||||
{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"
|
||||
<img
|
||||
src={logoSrc}
|
||||
alt="Logo"
|
||||
className="h-7 w-7 rounded-md 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 className="h-7 w-7 rounded-md bg-gradient-to-br from-primary/80 to-primary flex items-center justify-center">
|
||||
<span className="text-primary-foreground text-[10px] font-bold">AI</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="font-semibold text-lg tracking-tight">{header?.logoText || config.site?.title || 'PraisonAIUI'}</span>
|
||||
<span className="font-semibold text-[15px] tracking-tight truncate hidden sm:inline">{logoText}</span>
|
||||
</a>
|
||||
|
||||
<nav className="hidden md:flex items-center gap-1 flex-1">
|
||||
{header?.links?.map((link) => (
|
||||
{showMintlifyNav && (
|
||||
<nav className="hidden md:flex items-center gap-1 ml-2" aria-label="Primary docs sections">
|
||||
{tabs.map((tab, index) => (
|
||||
<button
|
||||
key={tab.tab}
|
||||
type="button"
|
||||
onClick={() => onTabChange?.(index)}
|
||||
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${index === activeTabIndex
|
||||
? 'text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{tab.tab}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{!showMintlifyNav && (
|
||||
<nav className="hidden md:flex items-center gap-1 flex-1 ml-2">
|
||||
{navbarLinks.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors rounded-md hover:bg-muted/50"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0" />
|
||||
|
||||
{config.search?.enabled !== false && showMintlifyNav && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSearchClick}
|
||||
className="hidden sm:flex items-center gap-2 h-9 w-full max-w-[220px] px-3 text-sm text-muted-foreground border border-border/60 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors"
|
||||
aria-label="Search documentation"
|
||||
>
|
||||
<Search className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1 text-left truncate">Search...</span>
|
||||
<kbd className="hidden lg:inline text-[10px] font-medium text-muted-foreground/80 border border-border/60 rounded px-1.5 py-0.5">⌘K</kbd>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{showMintlifyNav && navbarLinks.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="px-3 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors rounded-md hover:bg-muted/50"
|
||||
className="hidden lg:inline-flex px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<LocaleSwitcher />
|
||||
{header?.cta && (
|
||||
{primary && (
|
||||
<a
|
||||
href={header.cta.href}
|
||||
className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground shadow h-9 px-4 hover:bg-primary/90 transition-colors"
|
||||
href={primary.href}
|
||||
target={primary.href.startsWith('http') ? '_blank' : undefined}
|
||||
rel={primary.href.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||
className="inline-flex items-center justify-center gap-1.5 rounded-lg text-sm font-medium bg-primary text-primary-foreground h-9 px-3.5 hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
{header.cta.label}
|
||||
{primary.type === 'github' && <Github className="h-4 w-4" />}
|
||||
{primary.label ?? (primary.type === 'github' ? 'GitHub' : 'Get started')}
|
||||
</a>
|
||||
)}
|
||||
|
||||
<ThemeToggle />
|
||||
<LocaleSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,55 +1,111 @@
|
||||
// Sidebar component — navigation tree
|
||||
// Sidebar component — Mintlify-style navigation tree
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import type { NavItem } from './types'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { normalizeDocPath } from './pathUtils'
|
||||
import type { DocsNav, NavItem } from './types'
|
||||
|
||||
interface SidebarProps {
|
||||
nav: { items?: NavItem[] }
|
||||
interface NavTreeProps {
|
||||
nav: DocsNav
|
||||
activeItem: string
|
||||
onItemClick: (item: NavItem) => void
|
||||
}
|
||||
|
||||
export function Sidebar({ nav, activeItem, onItemClick }: SidebarProps) {
|
||||
export function NavTree({ nav, activeItem, onItemClick }: NavTreeProps) {
|
||||
const renderItem = (item: NavItem, depth = 0) => {
|
||||
const isActive = (item.path || item.title) === activeItem
|
||||
const hasChildren = item.children && item.children.length > 0
|
||||
|
||||
if (hasChildren) {
|
||||
return (
|
||||
<div key={item.title + (item.path || '')}>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 text-xs font-semibold text-muted-foreground/70 uppercase tracking-widest mt-4 first:mt-0">
|
||||
<div key={item.title + (item.path || '')} className="mb-4">
|
||||
<div className="flex items-center gap-1 px-3 py-1.5 text-[11px] font-semibold text-muted-foreground/80 uppercase tracking-wider">
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<div className="space-y-0.5 mt-1">
|
||||
{item.children!.map((child) => renderItem(child, depth + 1))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const href = normalizeDocPath(item.path || '/')
|
||||
|
||||
return (
|
||||
<button
|
||||
<a
|
||||
key={item.title + (item.path || '')}
|
||||
onClick={() => onItemClick(item)}
|
||||
className={`w-full text-left px-3 py-1.5 text-sm rounded-md transition-all duration-150 ${isActive
|
||||
? 'bg-primary/10 text-primary font-medium border-l-2 border-primary'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
||||
href={href}
|
||||
data-nav-path={item.path || ''}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
onItemClick(item)
|
||||
}}
|
||||
className={`group flex items-center gap-1.5 w-full text-left px-3 py-1.5 text-[13px] rounded-md transition-colors ${isActive
|
||||
? 'text-primary font-medium bg-primary/10'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
|
||||
}`}
|
||||
style={{ paddingLeft: `${12 + depth * 12}px` }}
|
||||
style={{ paddingLeft: `${12 + depth * 10}px` }}
|
||||
>
|
||||
{item.title}
|
||||
</button>
|
||||
{depth > 0 && (
|
||||
<ChevronRight className={`h-3 w-3 shrink-0 opacity-0 group-hover:opacity-40 ${isActive ? 'opacity-60 text-primary' : ''}`} />
|
||||
)}
|
||||
<span className="truncate">{item.title}</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="w-64 min-w-[16rem] border-r border-border/50 hidden md:block">
|
||||
<ScrollArea className="h-[calc(100vh-4rem)] py-4 px-2">
|
||||
<nav className="space-y-0.5">
|
||||
{nav.items?.map((group) => renderItem(group))}
|
||||
</nav>
|
||||
<Separator className="my-4" />
|
||||
<nav className="space-y-0.5 pb-6">
|
||||
{nav.items?.map((group) => renderItem(group))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
interface SidebarProps extends NavTreeProps {
|
||||
nav: DocsNav
|
||||
}
|
||||
|
||||
export function Sidebar({ nav, activeItem, onItemClick }: SidebarProps) {
|
||||
return (
|
||||
<aside className="docs-sidebar w-[17rem] min-w-[17rem] shrink-0 hidden md:block border-r border-border/40">
|
||||
<ScrollArea className="h-[calc(100vh-3.5rem)] sticky top-14">
|
||||
<div className="py-5 px-2">
|
||||
<NavTree nav={nav} activeItem={activeItem} onItemClick={onItemClick} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
interface MobileNavSheetProps extends NavTreeProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function MobileNavSheet({ open, onOpenChange, nav, activeItem, onItemClick, title = 'Navigation' }: MobileNavSheetProps) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="left" className="w-72 p-0">
|
||||
<SheetHeader className="border-b px-4 py-3">
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100vh-4rem)] px-2 py-4">
|
||||
<NavTree
|
||||
nav={nav}
|
||||
activeItem={activeItem}
|
||||
onItemClick={(item) => {
|
||||
onItemClick(item)
|
||||
onOpenChange(false)
|
||||
}}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
+137
-41
@@ -1,51 +1,147 @@
|
||||
// Table of Contents & right sidebar component
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { NavItem, ZonesConfig } from './types'
|
||||
import { ZoneWidgets } from './Widgets'
|
||||
|
||||
export function Toc({ selectedItem, zones }: { selectedItem: NavItem | null; zones?: ZonesConfig }) {
|
||||
const rightSidebarWidgets = zones?.rightSidebar || []
|
||||
interface TocHeading {
|
||||
id: string
|
||||
text: string
|
||||
level: number
|
||||
}
|
||||
|
||||
function collectHeadings(): TocHeading[] {
|
||||
const article = document.querySelector('#main-content article') ?? document.querySelector('article.prose')
|
||||
if (!article) return []
|
||||
|
||||
return Array.from(article.querySelectorAll('h2[id], h3[id], h4[id]')).map((el) => ({
|
||||
id: el.id,
|
||||
text: el.textContent?.trim() ?? '',
|
||||
level: Number(el.tagName.slice(1)),
|
||||
}))
|
||||
}
|
||||
|
||||
function usePageHeadings(selectedItem: NavItem | null) {
|
||||
const [headings, setHeadings] = useState<TocHeading[]>([])
|
||||
const [activeId, setActiveId] = useState('')
|
||||
|
||||
const refreshHeadings = useCallback(() => {
|
||||
setHeadings(collectHeadings())
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
refreshHeadings()
|
||||
const timer = window.setTimeout(refreshHeadings, 100)
|
||||
const timer2 = window.setTimeout(refreshHeadings, 500)
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
window.clearTimeout(timer2)
|
||||
}
|
||||
}, [selectedItem, refreshHeadings])
|
||||
|
||||
useEffect(() => {
|
||||
if (!headings.length) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((entry) => entry.isIntersecting)
|
||||
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
|
||||
if (visible[0]?.target.id) {
|
||||
setActiveId(visible[0].target.id)
|
||||
}
|
||||
},
|
||||
{ rootMargin: '-20% 0px -70% 0px', threshold: 0 },
|
||||
)
|
||||
|
||||
for (const heading of headings) {
|
||||
const el = document.getElementById(heading.id)
|
||||
if (el) observer.observe(el)
|
||||
}
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [headings])
|
||||
|
||||
return { headings, activeId }
|
||||
}
|
||||
|
||||
function TocLinks({ headings, activeId }: { headings: TocHeading[]; activeId: string }) {
|
||||
if (headings.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">No sections on this page.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="w-64 hidden lg:block border-l border-border/50">
|
||||
<div className="sticky top-20 px-4 py-6 space-y-4">
|
||||
{/* Table of Contents */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-muted-foreground/70 uppercase tracking-widest mb-4">
|
||||
On this page
|
||||
</h4>
|
||||
<nav className="space-y-2 text-sm">
|
||||
{selectedItem ? (
|
||||
<div className="space-y-2">
|
||||
<a href="#" className="flex items-center gap-2 text-primary font-medium">
|
||||
<span className="w-1 h-1 rounded-full bg-primary" />
|
||||
{selectedItem.title}
|
||||
</a>
|
||||
<a href="#overview" className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors pl-3">
|
||||
Overview
|
||||
</a>
|
||||
<a href="#usage" className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors pl-3">
|
||||
Usage
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<a href="#theme" className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors">
|
||||
<span className="w-1 h-1 rounded-full bg-muted-foreground/30" />
|
||||
Theme Configuration
|
||||
</a>
|
||||
<a href="#presets" className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors">
|
||||
<span className="w-1 h-1 rounded-full bg-muted-foreground/30" />
|
||||
Available Presets
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
{headings.map((heading) => {
|
||||
const isActive = heading.id === activeId
|
||||
return (
|
||||
<a
|
||||
key={heading.id}
|
||||
href={`#${heading.id}`}
|
||||
className={`flex items-start gap-2 text-[13px] leading-snug transition-colors ${heading.level === 3 ? 'pl-3' : heading.level === 4 ? 'pl-5' : ''
|
||||
} ${isActive
|
||||
? 'text-primary font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`mt-[0.45rem] h-1.5 w-1.5 shrink-0 rounded-full ${isActive ? 'bg-primary' : 'bg-muted-foreground/30'
|
||||
}`}
|
||||
/>
|
||||
<span>{heading.text}</span>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Zone Widgets (excluding Toc) */}
|
||||
{rightSidebarWidgets.filter(w => w.type !== 'Toc').length > 0 && (
|
||||
<div className="pt-4 border-t border-border/50">
|
||||
<ZoneWidgets widgets={rightSidebarWidgets.filter(w => w.type !== 'Toc')} />
|
||||
export function MobileToc({ selectedItem }: { selectedItem: NavItem | null }) {
|
||||
const { headings, activeId } = usePageHeadings(selectedItem)
|
||||
if (!selectedItem || headings.length === 0) return null
|
||||
|
||||
return (
|
||||
<details className="lg:hidden border border-border/60 rounded-lg mb-6 px-4 py-3 bg-muted/20">
|
||||
<summary className="text-sm font-medium cursor-pointer">On this page</summary>
|
||||
<nav className="mt-3">
|
||||
<TocLinks headings={headings} activeId={activeId} />
|
||||
</nav>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
export function Toc({
|
||||
selectedItem,
|
||||
zones,
|
||||
showToc = true,
|
||||
}: {
|
||||
selectedItem: NavItem | null
|
||||
zones?: ZonesConfig
|
||||
showToc?: boolean
|
||||
}) {
|
||||
const { headings, activeId } = usePageHeadings(selectedItem)
|
||||
const rightSidebarWidgets = zones?.rightSidebar || []
|
||||
|
||||
if (!showToc && rightSidebarWidgets.filter((w) => w.type !== 'Toc').length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="docs-toc w-[16rem] min-w-[16rem] shrink-0 hidden lg:block">
|
||||
<div className="sticky top-14 h-[calc(100vh-3.5rem)] overflow-y-auto px-4 py-6">
|
||||
{showToc && (
|
||||
<div>
|
||||
<h4 className="text-[11px] font-semibold text-muted-foreground/80 uppercase tracking-wider mb-4">
|
||||
On this page
|
||||
</h4>
|
||||
<nav>
|
||||
<TocLinks headings={headings} activeId={activeId} />
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rightSidebarWidgets.filter((w) => w.type !== 'Toc').length > 0 && (
|
||||
<div className="pt-4 mt-4 border-t border-border/40">
|
||||
<ZoneWidgets widgets={rightSidebarWidgets.filter((w) => w.type !== 'Toc')} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,22 +6,16 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
|
||||
type Theme = "light" | "dark" | "system"
|
||||
import { setThemePreference, type ThemePreference } from "../themes"
|
||||
|
||||
interface ThemeToggleProps {
|
||||
theme?: Theme
|
||||
onThemeChange?: (theme: Theme) => void
|
||||
theme?: ThemePreference
|
||||
onThemeChange?: (theme: ThemePreference) => void
|
||||
}
|
||||
|
||||
export function ThemeToggle({ onThemeChange }: ThemeToggleProps) {
|
||||
const setTheme = (newTheme: Theme) => {
|
||||
if (newTheme === "system") {
|
||||
const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
document.documentElement.classList.toggle("dark", systemDark)
|
||||
} else {
|
||||
document.documentElement.classList.toggle("dark", newTheme === "dark")
|
||||
}
|
||||
const setTheme = (newTheme: ThemePreference) => {
|
||||
setThemePreference(newTheme)
|
||||
onThemeChange?.(newTheme)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { NavItem, NavTabConfig } from '../types'
|
||||
|
||||
function sectionTitle(item: NavItem): string {
|
||||
return item.title.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function collectPaths(item: NavItem): string[] {
|
||||
const paths: string[] = []
|
||||
if (item.path) paths.push(item.path.toLowerCase())
|
||||
for (const child of item.children ?? []) {
|
||||
paths.push(...collectPaths(child))
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
function pathMatchesPage(docPath: string, page: string): boolean {
|
||||
const normalizedPage = page.replace(/^\/+/, '').toLowerCase()
|
||||
const normalizedPath = docPath.replace(/\/+$/, '').toLowerCase()
|
||||
|
||||
if (normalizedPage === 'index') {
|
||||
return normalizedPath.endsWith('/index') || normalizedPath === '/docs'
|
||||
}
|
||||
|
||||
const suffix = `/${normalizedPage}`
|
||||
return normalizedPath.endsWith(suffix) || normalizedPath.endsWith(`${suffix}/index`)
|
||||
}
|
||||
|
||||
/** Whether a sidebar group belongs to the given navigation tab (from topnav plugin logic). */
|
||||
export function navItemBelongsToTab(item: NavItem, tab: NavTabConfig): boolean {
|
||||
if (!tab.groups?.length) return false
|
||||
|
||||
const header = sectionTitle(item)
|
||||
const paths = collectPaths(item)
|
||||
|
||||
for (const grp of tab.groups) {
|
||||
const groupName = grp.group.toLowerCase()
|
||||
if (header === groupName) return true
|
||||
|
||||
if (grp.prefix) {
|
||||
const prefix = grp.prefix.toLowerCase()
|
||||
if (header === prefix || header === prefix.replace(/-/g, ' ')) return true
|
||||
|
||||
const prefixPath = `/docs/${prefix}`
|
||||
if (paths.some((path) => path === prefixPath || path.startsWith(`${prefixPath}/`))) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (grp.pages?.some((page) => paths.some((path) => pathMatchesPage(path, page)))) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!item.children?.length && grp.pages?.some((page) => {
|
||||
const slug = page.split('/').pop()?.replace(/-/g, ' ') ?? ''
|
||||
return header === slug || header === page.toLowerCase()
|
||||
})) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/** Pick the active tab index from the current URL path. */
|
||||
export function detectActiveTabIndex(tabs: NavTabConfig[], path: string): number {
|
||||
const normalized = path.toLowerCase()
|
||||
let bestMatch = -1
|
||||
let bestMatchLen = 0
|
||||
|
||||
for (let i = 0; i < tabs.length; i++) {
|
||||
const tab = tabs[i]
|
||||
for (const grp of tab.groups ?? []) {
|
||||
if (!grp.prefix) continue
|
||||
const needle = `/${grp.prefix.toLowerCase()}/`
|
||||
if (normalized.includes(needle) && needle.length > bestMatchLen) {
|
||||
bestMatch = i
|
||||
bestMatchLen = needle.length
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestMatch >= 0) return bestMatch
|
||||
return 0
|
||||
}
|
||||
|
||||
export function filterNavByTab(items: NavItem[], tab: NavTabConfig | undefined): NavItem[] {
|
||||
if (!tab) return items
|
||||
return items.filter((item) => navItemBelongsToTab(item, tab))
|
||||
}
|
||||
+152
-37
@@ -12,37 +12,37 @@
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-background: hsl(var(--background));
|
||||
--color-foreground: hsl(var(--foreground));
|
||||
--color-card: hsl(var(--card));
|
||||
--color-card-foreground: hsl(var(--card-foreground));
|
||||
--color-popover: hsl(var(--popover));
|
||||
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||
--color-primary: hsl(var(--primary));
|
||||
--color-primary-foreground: hsl(var(--primary-foreground));
|
||||
--color-secondary: hsl(var(--secondary));
|
||||
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
||||
--color-muted: hsl(var(--muted));
|
||||
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||
--color-accent: hsl(var(--accent));
|
||||
--color-accent-foreground: hsl(var(--accent-foreground));
|
||||
--color-destructive: hsl(var(--destructive));
|
||||
--color-border: hsl(var(--border));
|
||||
--color-input: hsl(var(--input));
|
||||
--color-ring: hsl(var(--ring));
|
||||
--color-chart-1: hsl(var(--chart-1));
|
||||
--color-chart-2: hsl(var(--chart-2));
|
||||
--color-chart-3: hsl(var(--chart-3));
|
||||
--color-chart-4: hsl(var(--chart-4));
|
||||
--color-chart-5: hsl(var(--chart-5));
|
||||
--color-sidebar: hsl(var(--sidebar));
|
||||
--color-sidebar-foreground: hsl(var(--sidebar-foreground));
|
||||
--color-sidebar-primary: hsl(var(--sidebar-primary));
|
||||
--color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground));
|
||||
--color-sidebar-accent: hsl(var(--sidebar-accent));
|
||||
--color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground));
|
||||
--color-sidebar-border: hsl(var(--sidebar-border));
|
||||
--color-sidebar-ring: hsl(var(--sidebar-ring));
|
||||
}
|
||||
|
||||
/* Color variables are injected via theme.css at build time.
|
||||
@@ -52,20 +52,37 @@
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Readable code blocks in light and dark prose */
|
||||
.prose pre {
|
||||
@apply bg-muted text-foreground border border-border;
|
||||
/* Readable prose in light and dark mode */
|
||||
.prose-neutral {
|
||||
--tw-prose-body: hsl(var(--foreground));
|
||||
--tw-prose-headings: hsl(var(--foreground));
|
||||
--tw-prose-bold: hsl(var(--foreground));
|
||||
--tw-prose-counters: hsl(var(--muted-foreground));
|
||||
--tw-prose-bullets: hsl(var(--muted-foreground));
|
||||
--tw-prose-pre-code: hsl(var(--foreground));
|
||||
--tw-prose-pre-bg: hsl(var(--muted));
|
||||
--tw-prose-code: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.prose pre code {
|
||||
.prose pre,
|
||||
.prose-neutral :where(pre) {
|
||||
@apply bg-muted text-foreground border border-border;
|
||||
color: hsl(var(--foreground)) !important;
|
||||
background-color: hsl(var(--muted)) !important;
|
||||
}
|
||||
|
||||
.prose pre code,
|
||||
.prose-neutral :where(pre code) {
|
||||
@apply text-foreground bg-transparent;
|
||||
color: inherit;
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
.prose :not(pre) > code {
|
||||
@@ -79,4 +96,102 @@
|
||||
.prose .aiui-tabs pre {
|
||||
@apply my-2;
|
||||
}
|
||||
|
||||
.mermaid-diagram {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 1.5rem 0;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 12px;
|
||||
overflow-x: auto;
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.mermaid-diagram-svg {
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
:root:not(.dark) .mermaid-diagram:not(.mermaid-error) {
|
||||
background: hsl(var(--muted));
|
||||
border: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.dark .mermaid-diagram:not(.mermaid-error) {
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
border: 1px solid rgba(148, 163, 184, 0.15);
|
||||
}
|
||||
|
||||
.mermaid-diagram:not(.mermaid-error):hover {
|
||||
border-color: rgba(20, 184, 166, 0.4);
|
||||
box-shadow: 0 0 24px rgba(13, 148, 136, 0.12);
|
||||
}
|
||||
|
||||
.mermaid-diagram svg {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
height: auto !important;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mermaid-diagram .nodeLabel,
|
||||
.mermaid-diagram .label div,
|
||||
.mermaid-diagram .cluster-label .nodeLabel {
|
||||
color: #ffffff !important;
|
||||
fill: #ffffff !important;
|
||||
font-family: "Inter", system-ui, -apple-system, sans-serif !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
.mermaid-diagram .edgeLabel {
|
||||
color: #e2e8f0 !important;
|
||||
fill: #e2e8f0 !important;
|
||||
background-color: rgba(15, 23, 42, 0.85) !important;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.mermaid-diagram .cluster-label .nodeLabel {
|
||||
font-weight: 600 !important;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.mermaid-diagram .node rect,
|
||||
.mermaid-diagram .node circle,
|
||||
.mermaid-diagram .node polygon {
|
||||
rx: 8;
|
||||
ry: 8;
|
||||
}
|
||||
|
||||
/* Mintlify-style docs shell */
|
||||
.docs-shell {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.docs-main-inner {
|
||||
min-height: calc(100vh - 3.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* Prose readability — must beat @tailwindcss/typography defaults (unlayered) */
|
||||
article.prose :where(p, li, td, th, blockquote, strong, em, h1, h3, h4, h5, h6) {
|
||||
color: hsl(var(--foreground)) !important;
|
||||
-webkit-text-fill-color: unset !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
article.prose :where(h2) {
|
||||
color: hsl(var(--primary)) !important;
|
||||
-webkit-text-fill-color: unset !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
article.prose .aiui-feature-grid > li {
|
||||
color: hsl(var(--foreground)) !important;
|
||||
}
|
||||
|
||||
.dark article.prose.dark\:prose-invert :where(p, li, td, th, blockquote, strong, h1, h2, h3, h4) {
|
||||
color: hsl(var(--foreground)) !important;
|
||||
-webkit-text-fill-color: unset !important;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { fitMermaidSvg, getMermaidConfig, isDarkMode } from './mermaidThemes'
|
||||
|
||||
let renderCounter = 0
|
||||
|
||||
function nextRenderId(baseId: string): string {
|
||||
renderCounter += 1
|
||||
return `mermaid-${baseId.replace(/:/g, '')}-${renderCounter}`
|
||||
}
|
||||
|
||||
export function MermaidDiagram({ chart }: { chart: string }) {
|
||||
const baseId = useId()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const svgRef = useRef<HTMLDivElement>(null)
|
||||
const bindFunctionsRef = useRef<((element: Element) => void) | undefined>(undefined)
|
||||
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [svgMarkup, setSvgMarkup] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const render = async () => {
|
||||
setStatus('loading')
|
||||
setErrorMessage('')
|
||||
setSvgMarkup('')
|
||||
const mermaid = (await import('mermaid')).default
|
||||
mermaid.initialize(getMermaidConfig(isDarkMode()))
|
||||
|
||||
const styles = getComputedStyle(container)
|
||||
const paddingX = parseFloat(styles.paddingLeft) + parseFloat(styles.paddingRight)
|
||||
const containerWidth = container.clientWidth - paddingX
|
||||
|
||||
try {
|
||||
const { svg, bindFunctions } = await mermaid.render(nextRenderId(baseId), chart)
|
||||
if (cancelled) return
|
||||
bindFunctionsRef.current = bindFunctions
|
||||
setSvgMarkup(fitMermaidSvg(svg, containerWidth))
|
||||
setStatus('ready')
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
setStatus('error')
|
||||
setErrorMessage(err instanceof Error ? err.message : 'Failed to render diagram')
|
||||
}
|
||||
}
|
||||
|
||||
void render()
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
void render()
|
||||
})
|
||||
observer.observe(container)
|
||||
|
||||
const themeObserver = new MutationObserver(() => {
|
||||
void render()
|
||||
})
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
observer.disconnect()
|
||||
themeObserver.disconnect()
|
||||
}
|
||||
}, [chart, baseId])
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== 'ready' || !svgRef.current) return
|
||||
bindFunctionsRef.current?.(svgRef.current)
|
||||
}, [status, svgMarkup])
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<pre className="mermaid-diagram mermaid-error text-destructive text-sm my-4 p-4 rounded-lg border border-destructive/30 bg-destructive/5 overflow-x-auto">
|
||||
{errorMessage}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`mermaid-diagram w-full${status === 'loading' ? ' mermaid-loading text-muted-foreground text-sm' : ''}`}
|
||||
aria-busy={status === 'loading'}
|
||||
aria-label={status === 'loading' ? 'Loading diagram' : 'Mermaid diagram'}
|
||||
>
|
||||
{status === 'loading' ? 'Loading diagram…' : null}
|
||||
{svgMarkup ? (
|
||||
<div ref={svgRef} className="mermaid-diagram-svg w-full" dangerouslySetInnerHTML={{ __html: svgMarkup }} />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/** Mermaid theme config — aligned with plugins/mermaid.js */
|
||||
|
||||
export const MERMAID_DARK_THEME = {
|
||||
theme: 'base' as const,
|
||||
themeVariables: {
|
||||
background: 'transparent',
|
||||
mainBkg: '#0d9488',
|
||||
primaryColor: '#0d9488',
|
||||
primaryBorderColor: '#14b8a6',
|
||||
primaryTextColor: '#ffffff',
|
||||
secondaryColor: '#6366f1',
|
||||
secondaryBorderColor: '#818cf8',
|
||||
secondaryTextColor: '#ffffff',
|
||||
tertiaryColor: '#e11d48',
|
||||
tertiaryBorderColor: '#fb7185',
|
||||
tertiaryTextColor: '#ffffff',
|
||||
textColor: '#f1f5f9',
|
||||
labelTextColor: '#f1f5f9',
|
||||
lineColor: '#94a3b8',
|
||||
arrowheadColor: '#cbd5e1',
|
||||
nodeBorder: '#14b8a6',
|
||||
clusterBkg: 'rgba(13, 148, 136, 0.08)',
|
||||
clusterBorder: 'rgba(20, 184, 166, 0.4)',
|
||||
defaultLinkColor: '#94a3b8',
|
||||
edgeLabelBackground: 'rgba(15, 23, 42, 0.85)',
|
||||
nodeTextColor: '#ffffff',
|
||||
actorBkg: '#0d9488',
|
||||
actorBorder: '#14b8a6',
|
||||
actorTextColor: '#ffffff',
|
||||
actorLineColor: '#64748b',
|
||||
signalColor: '#cbd5e1',
|
||||
signalTextColor: '#f1f5f9',
|
||||
activationBorderColor: '#14b8a6',
|
||||
activationBkgColor: 'rgba(13, 148, 136, 0.2)',
|
||||
sequenceNumberColor: '#ffffff',
|
||||
noteBkgColor: 'rgba(99, 102, 241, 0.2)',
|
||||
noteBorderColor: '#818cf8',
|
||||
noteTextColor: '#f1f5f9',
|
||||
titleColor: '#f8fafc',
|
||||
fontFamily: '"Inter", system-ui, -apple-system, sans-serif',
|
||||
fontSize: '16px',
|
||||
},
|
||||
}
|
||||
|
||||
export const MERMAID_LIGHT_THEME = {
|
||||
theme: 'base' as const,
|
||||
themeVariables: {
|
||||
background: 'transparent',
|
||||
mainBkg: '#0d9488',
|
||||
primaryColor: '#0d9488',
|
||||
primaryBorderColor: '#0f766e',
|
||||
primaryTextColor: '#ffffff',
|
||||
secondaryColor: '#6366f1',
|
||||
secondaryBorderColor: '#4f46e5',
|
||||
secondaryTextColor: '#ffffff',
|
||||
tertiaryColor: '#e11d48',
|
||||
tertiaryBorderColor: '#be123c',
|
||||
tertiaryTextColor: '#ffffff',
|
||||
textColor: '#1e293b',
|
||||
labelTextColor: '#334155',
|
||||
lineColor: '#94a3b8',
|
||||
arrowheadColor: '#64748b',
|
||||
nodeBorder: '#0f766e',
|
||||
clusterBkg: 'rgba(13, 148, 136, 0.06)',
|
||||
clusterBorder: 'rgba(15, 118, 110, 0.3)',
|
||||
defaultLinkColor: '#94a3b8',
|
||||
edgeLabelBackground: 'rgba(255, 255, 255, 0.9)',
|
||||
nodeTextColor: '#ffffff',
|
||||
actorBkg: '#0d9488',
|
||||
actorBorder: '#0f766e',
|
||||
actorTextColor: '#ffffff',
|
||||
actorLineColor: '#cbd5e1',
|
||||
signalColor: '#64748b',
|
||||
signalTextColor: '#1e293b',
|
||||
noteBkgColor: 'rgba(99, 102, 241, 0.1)',
|
||||
noteBorderColor: '#6366f1',
|
||||
noteTextColor: '#1e293b',
|
||||
titleColor: '#0f172a',
|
||||
fontFamily: '"Inter", system-ui, -apple-system, sans-serif',
|
||||
fontSize: '16px',
|
||||
},
|
||||
}
|
||||
|
||||
export function isDarkMode(): boolean {
|
||||
return document.documentElement.classList.contains('dark')
|
||||
}
|
||||
|
||||
export function getMermaidConfig(dark: boolean) {
|
||||
const theme = dark ? MERMAID_DARK_THEME : MERMAID_LIGHT_THEME
|
||||
return {
|
||||
startOnLoad: false,
|
||||
securityLevel: 'strict' as const,
|
||||
...theme,
|
||||
flowchart: {
|
||||
useMaxWidth: true,
|
||||
htmlLabels: true,
|
||||
curve: 'basis' as const,
|
||||
},
|
||||
sequence: {
|
||||
useMaxWidth: true,
|
||||
wrap: true,
|
||||
},
|
||||
er: { useMaxWidth: true },
|
||||
gantt: { useMaxWidth: true },
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure rendered SVG stretches to the diagram container (Mintlify-style). */
|
||||
export function fitMermaidSvg(svg: string, containerWidth: number): string {
|
||||
const width = Math.max(Math.floor(containerWidth), 320)
|
||||
return svg.replace(/<svg([^>]*)>/, (_match, attrs: string) => {
|
||||
const cleaned = attrs
|
||||
.replace(/\swidth="[^"]*"/g, '')
|
||||
.replace(/\sheight="[^"]*"/g, '')
|
||||
.replace(/\sstyle="[^"]*"/g, '')
|
||||
return `<svg${cleaned} width="${width}" style="width:100%;max-width:100%;height:auto;display:block;">`
|
||||
})
|
||||
}
|
||||
@@ -1,25 +1,23 @@
|
||||
import { isMkdocsTabHeading, mkdocsTabTitle } from './mkdocsPreprocess'
|
||||
|
||||
/** Post-render DOM fixes for MkDocs-style content (tabs, feature cards). */
|
||||
/** Post-render class-only fixes for MkDocs-style content. No DOM moves — React owns the tree. */
|
||||
export function enhanceMkdocsDom(root: HTMLElement): void {
|
||||
const articles = new Set<HTMLElement>()
|
||||
if (root.matches('article.prose, main .prose, .prose, main.flex-1, #main-content')) {
|
||||
if (root.matches('article.prose, #main-content article')) {
|
||||
articles.add(root)
|
||||
}
|
||||
root.querySelectorAll<HTMLElement>('article.prose, main .prose, .prose').forEach((el) => articles.add(el))
|
||||
root.querySelectorAll<HTMLElement>('article.prose, #main-content article').forEach((el) => articles.add(el))
|
||||
|
||||
for (const article of articles) {
|
||||
removeRawDivParagraphs(article)
|
||||
hideRawDivParagraphs(article)
|
||||
enhanceFeatureGrid(article)
|
||||
enhanceTabs(article)
|
||||
}
|
||||
}
|
||||
|
||||
function removeRawDivParagraphs(article: HTMLElement): void {
|
||||
/** Hide MkDocs artefact paragraphs — never remove nodes React may still own. */
|
||||
function hideRawDivParagraphs(article: HTMLElement): void {
|
||||
for (const p of article.querySelectorAll('p')) {
|
||||
const text = p.textContent?.trim() ?? ''
|
||||
if (/^<\/?div[\s>]/.test(text) || text === '</div>') {
|
||||
p.remove()
|
||||
p.classList.add('hidden')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,97 +51,5 @@ function enhanceFeatureGrid(article: HTMLElement): void {
|
||||
}
|
||||
}
|
||||
|
||||
interface TabSection {
|
||||
heading: HTMLHeadingElement
|
||||
title: string
|
||||
nodes: Node[]
|
||||
}
|
||||
|
||||
function enhanceTabs(article: HTMLElement): void {
|
||||
if (article.querySelector('[data-aiui-tabs]')) return
|
||||
|
||||
const tabHeadings = Array.from(article.querySelectorAll<HTMLHeadingElement>('h3')).filter((h3) =>
|
||||
isMkdocsTabHeading(h3.textContent?.trim() ?? ''),
|
||||
)
|
||||
if (tabHeadings.length < 2) return
|
||||
|
||||
const sections: TabSection[] = tabHeadings.map((heading) => {
|
||||
const title = mkdocsTabTitle(heading.textContent?.trim() ?? '')
|
||||
const nodes: Node[] = []
|
||||
let sibling = heading.nextSibling
|
||||
|
||||
while (sibling) {
|
||||
if (sibling.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = sibling as HTMLElement
|
||||
if (el.tagName === 'H3' && isMkdocsTabHeading(el.textContent?.trim() ?? '')) break
|
||||
if (['H1', 'H2'].includes(el.tagName)) break
|
||||
}
|
||||
const next = sibling.nextSibling
|
||||
nodes.push(sibling)
|
||||
sibling = next
|
||||
}
|
||||
|
||||
return { heading, title, nodes }
|
||||
})
|
||||
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.className = 'aiui-tabs my-6 border border-border rounded-lg overflow-hidden'
|
||||
wrapper.dataset.aiuiTabs = 'true'
|
||||
|
||||
const buttons = document.createElement('div')
|
||||
buttons.className = 'aiui-tab-buttons flex flex-wrap gap-0 border-b border-border bg-muted/30'
|
||||
wrapper.appendChild(buttons)
|
||||
|
||||
const panels = document.createElement('div')
|
||||
panels.className = 'aiui-tab-panels'
|
||||
wrapper.appendChild(panels)
|
||||
|
||||
sections.forEach((section, index) => {
|
||||
const btn = document.createElement('button')
|
||||
btn.type = 'button'
|
||||
btn.className = [
|
||||
'aiui-tab-button px-4 py-2 text-sm font-medium transition-colors',
|
||||
index === 0
|
||||
? 'bg-background text-foreground border-b-2 border-primary'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
].join(' ')
|
||||
btn.textContent = section.title
|
||||
btn.dataset.tabIndex = String(index)
|
||||
buttons.appendChild(btn)
|
||||
|
||||
const panel = document.createElement('div')
|
||||
panel.className = index === 0 ? 'aiui-tab-panel p-4 block' : 'aiui-tab-panel p-4 hidden'
|
||||
panel.dataset.tabPanel = String(index)
|
||||
for (const node of section.nodes) {
|
||||
panel.appendChild(node)
|
||||
}
|
||||
panels.appendChild(panel)
|
||||
})
|
||||
|
||||
const insertPoint = sections[0].heading
|
||||
article.insertBefore(wrapper, insertPoint)
|
||||
|
||||
for (const section of sections) {
|
||||
section.heading.remove()
|
||||
}
|
||||
|
||||
buttons.addEventListener('click', (event) => {
|
||||
const target = (event.target as HTMLElement).closest<HTMLButtonElement>('.aiui-tab-button')
|
||||
if (!target) return
|
||||
const index = target.dataset.tabIndex ?? '0'
|
||||
|
||||
for (const btn of buttons.querySelectorAll<HTMLButtonElement>('.aiui-tab-button')) {
|
||||
const active = btn.dataset.tabIndex === index
|
||||
btn.classList.toggle('bg-background', active)
|
||||
btn.classList.toggle('text-foreground', active)
|
||||
btn.classList.toggle('border-b-2', active)
|
||||
btn.classList.toggle('border-primary', active)
|
||||
btn.classList.toggle('text-muted-foreground', !active)
|
||||
}
|
||||
|
||||
for (const panel of panels.querySelectorAll<HTMLElement>('.aiui-tab-panel')) {
|
||||
panel.classList.toggle('hidden', panel.dataset.tabPanel !== index)
|
||||
panel.classList.toggle('block', panel.dataset.tabPanel === index)
|
||||
}
|
||||
})
|
||||
}
|
||||
/** No-op — React article must not receive foreign plugin nodes. */
|
||||
export function teardownMkdocsEnhancements(_root: HTMLElement): void {}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/** Canonical docs path: no trailing slash except root. */
|
||||
export function normalizeDocPath(path: string): string {
|
||||
const cleaned = (path || '/').replace(/\/+$/, '')
|
||||
return cleaned || '/'
|
||||
}
|
||||
|
||||
/** Normalise an internal docs href for SEO-friendly crawlable links. */
|
||||
export function normalizeDocHref(href: string): string {
|
||||
if (!href || href.startsWith('#')) return href
|
||||
try {
|
||||
const url = new URL(href, window.location.origin)
|
||||
if (url.origin !== window.location.origin) return href
|
||||
return normalizeDocPath(url.pathname) + url.search + url.hash
|
||||
} catch {
|
||||
return href
|
||||
}
|
||||
}
|
||||
|
||||
export function isInternalDocHref(href?: string): boolean {
|
||||
if (!href || href.startsWith('#')) return false
|
||||
if (href.startsWith('/docs')) return true
|
||||
try {
|
||||
const url = new URL(href, window.location.origin)
|
||||
return url.origin === window.location.origin && url.pathname.startsWith('/docs')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Map a docs URL path to a markdown file path (leading slash, .md suffix). */
|
||||
export function docPathToMarkdown(path: string): string {
|
||||
const normalized = normalizeDocPath(path)
|
||||
if (normalized === '/') {
|
||||
return '/docs/index.md'
|
||||
}
|
||||
return `${normalized}.md`
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { RouteManifest, SlotConfig, TemplateConfig } from './types'
|
||||
|
||||
export interface TemplateMatch {
|
||||
template: string
|
||||
layout: string
|
||||
slots: Record<string, SlotConfig | null | undefined>
|
||||
slotOverrides?: Record<string, SlotConfig | null>
|
||||
}
|
||||
|
||||
export function matchPattern(path: string, pattern: string): boolean {
|
||||
const normalizedPath = path.replace(/^\/+|\/+$/g, '')
|
||||
const normalizedPattern = pattern.replace(/^\/+|\/+$/g, '')
|
||||
|
||||
const regexPattern = normalizedPattern
|
||||
.replace(/\*\*/g, '{{GLOB}}')
|
||||
.replace(/\*/g, '[^/]+')
|
||||
.replace(/\{\{GLOB\}\}/g, '.*')
|
||||
|
||||
return new RegExp(`^${regexPattern}$`).test(normalizedPath)
|
||||
}
|
||||
|
||||
export function resolveTemplate(
|
||||
path: string,
|
||||
manifest: RouteManifest,
|
||||
templates: Record<string, TemplateConfig>,
|
||||
): TemplateMatch | null {
|
||||
const routes = manifest.routes ?? []
|
||||
const sortedRoutes = [...routes].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
|
||||
|
||||
for (const route of sortedRoutes) {
|
||||
if (!matchPattern(path, route.pattern)) continue
|
||||
|
||||
const template = templates[route.template]
|
||||
if (!template) {
|
||||
console.warn(`Template "${route.template}" not found`)
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
template: route.template,
|
||||
layout: template.layout ?? 'ThreeColumnLayout',
|
||||
slots: template.slots ?? {},
|
||||
slotOverrides: route.slotOverrides,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function mergedSlot(
|
||||
slotName: string,
|
||||
match: TemplateMatch | null,
|
||||
): SlotConfig | null | undefined {
|
||||
if (!match) return undefined
|
||||
if (match.slotOverrides && slotName in match.slotOverrides) {
|
||||
return match.slotOverrides[slotName]
|
||||
}
|
||||
return match.slots[slotName]
|
||||
}
|
||||
|
||||
export function shouldShowToc(match: TemplateMatch | null): boolean {
|
||||
const right = mergedSlot('right', match)
|
||||
return right !== null
|
||||
}
|
||||
@@ -136,7 +136,9 @@ export const NAMED_THEME_PRESETS = {
|
||||
|
||||
export type NamedThemePreset = keyof typeof NAMED_THEME_PRESETS
|
||||
|
||||
export type ThemePreset = typeof THEME_PRESETS[number]
|
||||
export const THEME_STORAGE_KEY = 'aiui-theme-preference'
|
||||
|
||||
export type ThemePreference = 'light' | 'dark' | 'system'
|
||||
|
||||
export const RADIUS_PRESETS = {
|
||||
none: '0',
|
||||
@@ -146,6 +148,8 @@ export const RADIUS_PRESETS = {
|
||||
xl: '1rem',
|
||||
} as const
|
||||
|
||||
export type ThemePreset = typeof THEME_PRESETS[number]
|
||||
|
||||
export type RadiusPreset = keyof typeof RADIUS_PRESETS
|
||||
|
||||
// CSS variable names that shadcn/Tailwind expects
|
||||
@@ -199,6 +203,43 @@ export function isDarkMode(): boolean {
|
||||
return document.documentElement.classList.contains('dark')
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply stored user theme preference over YAML default dark mode.
|
||||
*/
|
||||
export function applyStoredThemePreference(yamlDefaultDark: boolean): void {
|
||||
let stored: string | null = null
|
||||
try {
|
||||
stored = localStorage.getItem(THEME_STORAGE_KEY)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (stored === 'light') {
|
||||
applyDarkMode(false)
|
||||
} else if (stored === 'dark') {
|
||||
applyDarkMode(true)
|
||||
} else if (stored === 'system') {
|
||||
applyDarkMode(window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
} else {
|
||||
applyDarkMode(yamlDefaultDark)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist and apply a theme preference.
|
||||
*/
|
||||
export function setThemePreference(preference: ThemePreference): void {
|
||||
try {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, preference)
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
if (preference === 'system') {
|
||||
applyDarkMode(window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
} else {
|
||||
applyDarkMode(preference === 'dark')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of available theme presets
|
||||
*/
|
||||
|
||||
@@ -4,6 +4,8 @@ export interface NavItem {
|
||||
title: string
|
||||
href?: string
|
||||
path?: string
|
||||
description?: string
|
||||
noindex?: boolean
|
||||
children?: NavItem[]
|
||||
}
|
||||
|
||||
@@ -24,6 +26,8 @@ export interface SEOConfig {
|
||||
titleTemplate?: string
|
||||
defaultImage?: string
|
||||
twitter?: Record<string, string>
|
||||
siteUrl?: string
|
||||
robots?: { index?: boolean; follow?: boolean }
|
||||
}
|
||||
|
||||
export interface I18nConfig {
|
||||
@@ -47,6 +51,44 @@ export interface LogoConfig {
|
||||
href?: string
|
||||
}
|
||||
|
||||
export interface NavTabGroupConfig {
|
||||
group: string
|
||||
icon?: string
|
||||
prefix?: string
|
||||
pages?: string[]
|
||||
}
|
||||
|
||||
export interface NavTabConfig {
|
||||
tab: string
|
||||
url?: string
|
||||
groups?: NavTabGroupConfig[]
|
||||
}
|
||||
|
||||
export interface NavigationConfig {
|
||||
tabs?: NavTabConfig[]
|
||||
}
|
||||
|
||||
export interface NavbarLinkConfig {
|
||||
label: string
|
||||
href: string
|
||||
}
|
||||
|
||||
export interface NavbarPrimaryConfig {
|
||||
type?: 'button' | 'github'
|
||||
label?: string
|
||||
href: string
|
||||
}
|
||||
|
||||
export interface NavbarConfig {
|
||||
primary?: NavbarPrimaryConfig
|
||||
links?: NavbarLinkConfig[]
|
||||
}
|
||||
|
||||
export interface SearchConfig {
|
||||
enabled?: boolean
|
||||
provider?: string
|
||||
}
|
||||
|
||||
export interface WidgetConfig {
|
||||
type: string
|
||||
props?: Record<string, unknown>
|
||||
@@ -89,6 +131,13 @@ export interface UIConfig {
|
||||
i18n?: I18nConfig
|
||||
a11y?: A11yConfig
|
||||
logo?: LogoConfig
|
||||
navigation?: NavigationConfig
|
||||
navbar?: NavbarConfig
|
||||
search?: SearchConfig
|
||||
footer?: {
|
||||
socials?: Record<string, string>
|
||||
links?: { header: string; items: NavbarLinkConfig[] }[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface DocsNav {
|
||||
@@ -96,7 +145,12 @@ export interface DocsNav {
|
||||
}
|
||||
|
||||
export interface RouteManifest {
|
||||
routes?: { pattern: string; template: string }[]
|
||||
routes?: {
|
||||
pattern: string
|
||||
template: string
|
||||
priority?: number
|
||||
slotOverrides?: Record<string, SlotConfig | null>
|
||||
}[]
|
||||
}
|
||||
|
||||
// Message element types (matching Python schema)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Version information."""
|
||||
|
||||
__version__ = "0.3.141"
|
||||
__version__ = "0.3.143"
|
||||
@@ -65,7 +65,8 @@ class Compiler:
|
||||
|
||||
# Ensure output directory exists
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
files: list[str] = []
|
||||
(output_dir / ".nojekyll").touch()
|
||||
files: list[str] = [".nojekyll"]
|
||||
|
||||
indent = None if minify else 2
|
||||
|
||||
@@ -111,6 +112,11 @@ class Compiler:
|
||||
nav = self._generate_docs_nav()
|
||||
route_files = self._generate_route_pages(output_dir, nav)
|
||||
files.extend(route_files)
|
||||
files.extend(self._generate_sitemap(output_dir, nav))
|
||||
files.extend(self._generate_robots(output_dir))
|
||||
files.extend(self._generate_llms_txt(output_dir, nav))
|
||||
files.extend(self._generate_llms_full_txt(output_dir, nav))
|
||||
self._patch_homepage_seo(output_dir, nav)
|
||||
|
||||
return CompileResult(success=True, files=files)
|
||||
|
||||
@@ -322,6 +328,8 @@ class Compiler:
|
||||
"titleTemplate": self.config.seo.title_template,
|
||||
"defaultImage": self.config.seo.default_image,
|
||||
"twitter": self.config.seo.twitter,
|
||||
"siteUrl": self.config.seo.site_url or self._site_url(),
|
||||
"robots": self.config.seo.robots,
|
||||
}
|
||||
|
||||
# A11y configuration - basic implementation for frontend
|
||||
@@ -441,6 +449,7 @@ class Compiler:
|
||||
output_dir / "index.html",
|
||||
)
|
||||
self._patch_antiflicker(output_dir / "index.html")
|
||||
self._patch_static_shell(output_dir / "index.html")
|
||||
# Create 404.html as a copy of patched index.html
|
||||
# for SPA routing on static hosts like GitHub Pages
|
||||
shutil.copy(
|
||||
@@ -470,15 +479,39 @@ class Compiler:
|
||||
self._copy_plugins(output_dir, frontend_dir)
|
||||
|
||||
def _patch_antiflicker(self, html_path: Path) -> None:
|
||||
"""Patch anti-flicker script in index.html to respect darkMode."""
|
||||
"""Patch theme bootstrap in index.html to respect darkMode."""
|
||||
theme = self.config.site.theme
|
||||
dark_mode = theme.dark_mode if theme else True
|
||||
html = html_path.read_text(encoding="utf-8")
|
||||
if not dark_mode:
|
||||
# Remove the dark class addition for light-mode sites
|
||||
html = html.replace("__AIUI_DARK_DEFAULT__", "true" if dark_mode else "false")
|
||||
html_path.write_text(html, encoding="utf-8")
|
||||
|
||||
def _patch_static_shell(self, html_path: Path) -> None:
|
||||
"""Patch built index.html for static hosts (GitHub Pages).
|
||||
|
||||
- Strip trailing slashes from the URL bar before React hydrates.
|
||||
- Load plugin-loader.js (not included in the Vite bundle).
|
||||
"""
|
||||
html = html_path.read_text(encoding="utf-8")
|
||||
canonical_script = (
|
||||
'<script id="aiui-canonical-url">'
|
||||
"(function(){var p=location.pathname.replace(/\\/+$/, '')||'/';"
|
||||
"if(p!==location.pathname){history.replaceState(null,'',"
|
||||
"p+location.search+location.hash);}})();"
|
||||
"</script>"
|
||||
)
|
||||
if 'id="aiui-canonical-url"' not in html:
|
||||
html = html.replace("<head>", f"<head>\n {canonical_script}", 1)
|
||||
if 'rel="sitemap"' not in html:
|
||||
html = html.replace(
|
||||
"document.documentElement.classList.add('dark');",
|
||||
"/* light mode — no dark class */",
|
||||
"<head>",
|
||||
'<head>\n <link rel="sitemap" type="application/xml" href="/sitemap.xml" />',
|
||||
1,
|
||||
)
|
||||
if "plugin-loader.js" not in html:
|
||||
html = html.replace(
|
||||
"</body>",
|
||||
' <script src="/plugins/plugin-loader.js"></script>\n</body>',
|
||||
)
|
||||
html_path.write_text(html, encoding="utf-8")
|
||||
|
||||
@@ -528,8 +561,10 @@ class Compiler:
|
||||
def _generate_route_pages(self, output_dir: Path, nav: dict) -> list[str]:
|
||||
"""Generate per-route HTML files for SPA fallback and SEO.
|
||||
|
||||
For each page in the docs navigation, creates an index.html at
|
||||
the route path (e.g. _site/docs/concepts/configuration/index.html).
|
||||
For each page in the docs navigation, creates a flat HTML file at
|
||||
the route path (e.g. _site/docs/concepts/configuration.html).
|
||||
GitHub Pages serves /docs/concepts/configuration from that file
|
||||
without appending a trailing slash.
|
||||
Each file is a copy of the SPA shell with page-specific:
|
||||
- <title> tag
|
||||
- <meta name="description">
|
||||
@@ -554,15 +589,16 @@ class Compiler:
|
||||
path = page["path"] # e.g. "/docs/concepts/configuration"
|
||||
title = page["title"] # e.g. "YAML Configuration"
|
||||
|
||||
# Build file system path
|
||||
# Build file system path — flat .html file so static hosts (GitHub Pages)
|
||||
# serve /docs/testing without a trailing-slash redirect.
|
||||
relative = path.lstrip("/")
|
||||
page_dir = output_dir / relative
|
||||
page_dir.mkdir(parents=True, exist_ok=True)
|
||||
page_file = page_dir / "index.html"
|
||||
page_file = output_dir / f"{relative}.html"
|
||||
page_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Build page-specific HTML
|
||||
page_title = f"{title} | {site_title}"
|
||||
page_desc = f"{title} - {site_desc}"
|
||||
page_desc = page.get("description") or f"{title} - {site_desc}"
|
||||
page_noindex = bool(page.get("noindex"))
|
||||
|
||||
html = template_html
|
||||
|
||||
@@ -579,50 +615,283 @@ class Compiler:
|
||||
)
|
||||
|
||||
# Add canonical URL, OG tags, and noscript content before </head>
|
||||
seo_tags = self._build_seo_tags(path, page_title, page_desc)
|
||||
seo_tags = self._build_seo_tags(path, page_title, page_desc, noindex=page_noindex)
|
||||
json_ld = self._build_json_ld(path, title, page_desc)
|
||||
|
||||
# Try to load markdown content for noscript pre-rendering
|
||||
noscript_content = self._get_noscript_content(output_dir, path)
|
||||
crawl_nav = self._build_crawl_nav_html(nav)
|
||||
noscript_block = crawl_nav
|
||||
if noscript_content:
|
||||
noscript_block += noscript_content
|
||||
|
||||
# Inject SEO tags before </head>
|
||||
html = html.replace(
|
||||
"</head>",
|
||||
f"{seo_tags}\n</head>",
|
||||
f"{seo_tags}\n{json_ld}\n</head>",
|
||||
)
|
||||
|
||||
# Inject noscript block inside <div id="root">
|
||||
if noscript_content:
|
||||
html = html.replace(
|
||||
'<div id="root"></div>',
|
||||
f'<div id="root"><noscript>{noscript_content}</noscript></div>',
|
||||
)
|
||||
# Inject noscript crawl content inside <div id="root">
|
||||
html = html.replace(
|
||||
'<div id="root"></div>',
|
||||
f'<div id="root"><noscript>{noscript_block}</noscript></div>',
|
||||
)
|
||||
|
||||
page_file.write_text(html, encoding="utf-8")
|
||||
files.append(f"{relative}/index.html")
|
||||
files.append(f"{relative}.html")
|
||||
|
||||
return files
|
||||
|
||||
def _collect_nav_pages(self, nav: dict) -> list[dict]:
|
||||
"""Recursively collect all pages from docs-nav.json."""
|
||||
pages: list[dict] = []
|
||||
for item in nav.get("items", []):
|
||||
if item.get("path"):
|
||||
pages.append({"path": item["path"], "title": item.get("title", "")})
|
||||
for child in item.get("children", []):
|
||||
if child.get("path"):
|
||||
pages.append({"path": child["path"], "title": child.get("title", "")})
|
||||
# Handle deeper nesting if present
|
||||
for grandchild in child.get("children", []):
|
||||
if grandchild.get("path"):
|
||||
pages.append(
|
||||
{"path": grandchild["path"], "title": grandchild.get("title", "")}
|
||||
)
|
||||
|
||||
def walk(items: list[dict]) -> None:
|
||||
for item in items:
|
||||
if item.get("path"):
|
||||
pages.append(
|
||||
{
|
||||
"path": item["path"],
|
||||
"title": item.get("title", ""),
|
||||
"description": item.get("description", ""),
|
||||
"noindex": bool(item.get("noindex")),
|
||||
}
|
||||
)
|
||||
if item.get("children"):
|
||||
walk(item["children"])
|
||||
|
||||
walk(nav.get("items", []))
|
||||
return pages
|
||||
|
||||
def _build_seo_tags(self, path: str, title: str, description: str) -> str:
|
||||
def _site_url(self) -> str | None:
|
||||
if self.config.seo and self.config.seo.site_url:
|
||||
return self.config.seo.site_url.rstrip("/")
|
||||
for candidate in (self.base_path / "CNAME", self.base_path / "docs" / "CNAME"):
|
||||
if candidate.exists():
|
||||
domain = candidate.read_text(encoding="utf-8").strip()
|
||||
if domain and "." in domain:
|
||||
return f"https://{domain.rstrip('/')}"
|
||||
return None
|
||||
|
||||
def _absolute_url(self, path: str) -> str:
|
||||
site = self._site_url()
|
||||
if not site:
|
||||
return path
|
||||
if not path.startswith("/"):
|
||||
path = f"/{path}"
|
||||
return f"{site}{path}"
|
||||
|
||||
def _generate_sitemap(self, output_dir: Path, nav: dict) -> list[str]:
|
||||
site = self._site_url()
|
||||
if not site:
|
||||
return []
|
||||
|
||||
urls = []
|
||||
site_root = self._absolute_url("/")
|
||||
root_lastmod = self._format_lastmod(self.base_path / "docs" / "index.md")
|
||||
urls.append(self._sitemap_url_entry(site_root, root_lastmod))
|
||||
for page in self._collect_nav_pages(nav):
|
||||
if page.get("noindex"):
|
||||
continue
|
||||
path = page["path"].rstrip("/") or "/"
|
||||
loc = self._absolute_url(path)
|
||||
lastmod = self._format_lastmod(self._resolve_md_source(path))
|
||||
urls.append(self._sitemap_url_entry(loc, lastmod))
|
||||
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
||||
+ "\n".join(urls)
|
||||
+ "\n</urlset>\n"
|
||||
)
|
||||
(output_dir / "sitemap.xml").write_text(xml, encoding="utf-8")
|
||||
return ["sitemap.xml"]
|
||||
|
||||
@staticmethod
|
||||
def _sitemap_url_entry(loc: str, lastmod: str | None) -> str:
|
||||
safe_loc = Compiler._escape_html(loc)
|
||||
if lastmod:
|
||||
return f" <url><loc>{safe_loc}</loc><lastmod>{lastmod}</lastmod></url>"
|
||||
return f" <url><loc>{safe_loc}</loc></url>"
|
||||
|
||||
@staticmethod
|
||||
def _format_lastmod(path: Path | None) -> str | None:
|
||||
if not path or not path.is_file():
|
||||
return None
|
||||
from datetime import datetime, timezone
|
||||
|
||||
mtime = path.stat().st_mtime
|
||||
return datetime.fromtimestamp(mtime, tz=timezone.utc).strftime("%Y-%m-%d")
|
||||
|
||||
def _resolve_md_source(self, route_path: str) -> Path | None:
|
||||
"""Map a docs route path to its source markdown file."""
|
||||
if not self.config.content or not self.config.content.docs:
|
||||
return None
|
||||
|
||||
base = self.config.site.route_base_docs.rstrip("/")
|
||||
if not route_path.startswith(base):
|
||||
return None
|
||||
|
||||
rel = route_path[len(base) :].lstrip("/")
|
||||
docs_dir = self.base_path / self.config.content.docs.dir
|
||||
candidates = [
|
||||
docs_dir / f"{rel}.md",
|
||||
docs_dir / rel / "index.md",
|
||||
docs_dir / rel / "README.md",
|
||||
]
|
||||
if rel in ("", "index"):
|
||||
candidates.insert(0, docs_dir / "index.md")
|
||||
|
||||
for candidate in candidates:
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
def _generate_robots(self, output_dir: Path) -> list[str]:
|
||||
robots = self.config.seo.robots if self.config.seo else None
|
||||
allow_index = robots.get("index", True) if robots else True
|
||||
allow_follow = robots.get("follow", True) if robots else True
|
||||
|
||||
lines = ["User-agent: *"]
|
||||
if allow_index:
|
||||
lines.append("Allow: /")
|
||||
else:
|
||||
lines.append("Disallow: /")
|
||||
if not allow_follow:
|
||||
lines.append("Disallow: /docs/")
|
||||
|
||||
site = self._site_url()
|
||||
if site:
|
||||
lines.append(f"Sitemap: {site}/sitemap.xml")
|
||||
lines.append(f"# LLM index: {site}/llms.txt")
|
||||
|
||||
(output_dir / "robots.txt").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
return ["robots.txt"]
|
||||
|
||||
def _generate_llms_txt(self, output_dir: Path, nav: dict) -> list[str]:
|
||||
"""Generate llms.txt index for AI crawlers (llmstxt.org convention)."""
|
||||
site = self._site_url()
|
||||
if not site:
|
||||
return []
|
||||
|
||||
site_title = self.config.site.title
|
||||
site_desc = self.config.site.description or f"Documentation for {site_title}"
|
||||
lines = [
|
||||
f"# {site_title}",
|
||||
"",
|
||||
f"> {site_desc}",
|
||||
"",
|
||||
"## Documentation",
|
||||
"",
|
||||
]
|
||||
|
||||
for page in self._collect_nav_pages(nav):
|
||||
if page.get("noindex"):
|
||||
continue
|
||||
path = page["path"].rstrip("/") or "/"
|
||||
title = page["title"]
|
||||
desc = page.get("description") or title
|
||||
url = self._absolute_url(path)
|
||||
lines.append(f"- [{title}]({url}): {desc}")
|
||||
|
||||
lines.extend(["", "## Full export", "", f"- [Complete documentation]({site}/llms-full.txt): All pages in one file"])
|
||||
content = "\n".join(lines).rstrip() + "\n"
|
||||
(output_dir / "llms.txt").write_text(content, encoding="utf-8")
|
||||
return ["llms.txt"]
|
||||
|
||||
def _generate_llms_full_txt(self, output_dir: Path, nav: dict) -> list[str]:
|
||||
"""Generate llms-full.txt — concatenated markdown for AI crawlers."""
|
||||
site = self._site_url()
|
||||
if not site:
|
||||
return []
|
||||
|
||||
sections: list[str] = []
|
||||
for page in self._collect_nav_pages(nav):
|
||||
if page.get("noindex"):
|
||||
continue
|
||||
md_path = self._resolve_md_source(page["path"])
|
||||
if not md_path:
|
||||
continue
|
||||
body = self._strip_frontmatter(md_path.read_text(encoding="utf-8")).strip()
|
||||
if not body:
|
||||
continue
|
||||
url = self._absolute_url(page["path"].rstrip("/") or "/")
|
||||
title = page["title"]
|
||||
sections.append(f"# {title}\n\nSource: {url}\n\n{body}")
|
||||
|
||||
if not sections:
|
||||
return []
|
||||
|
||||
(output_dir / "llms-full.txt").write_text("\n\n---\n\n".join(sections) + "\n", encoding="utf-8")
|
||||
return ["llms-full.txt"]
|
||||
|
||||
@staticmethod
|
||||
def _strip_frontmatter(content: str) -> str:
|
||||
if content.startswith("---"):
|
||||
end_idx = content.find("---", 3)
|
||||
if end_idx != -1:
|
||||
return content[end_idx + 3 :].lstrip("\n")
|
||||
return content
|
||||
|
||||
def _patch_homepage_seo(self, output_dir: Path, nav: dict) -> None:
|
||||
"""Add canonical and crawl hints to root index.html."""
|
||||
index_path = output_dir / "index.html"
|
||||
if not index_path.exists():
|
||||
return
|
||||
|
||||
docs_home = "/docs/index"
|
||||
for page in self._collect_nav_pages(nav):
|
||||
if page["path"].rstrip("/").endswith("/docs/index") or page["path"] == "/docs/index":
|
||||
docs_home = page["path"].rstrip("/") or "/docs/index"
|
||||
break
|
||||
|
||||
site_title = self.config.site.title
|
||||
site_desc = self.config.site.description or f"Documentation for {site_title}"
|
||||
page_title = f"{site_title}"
|
||||
seo_tags = self._build_seo_tags(docs_home, page_title, site_desc)
|
||||
crawl_nav = self._build_crawl_nav_html(nav)
|
||||
|
||||
html = index_path.read_text(encoding="utf-8")
|
||||
html = html.replace(
|
||||
"<title>Documentation</title>",
|
||||
f"<title>{self._escape_html(page_title)}</title>",
|
||||
)
|
||||
html = html.replace(
|
||||
'content="Documentation built with PraisonAIUI"',
|
||||
f'content="{self._escape_html(site_desc)}"',
|
||||
)
|
||||
if "</head>" in html and 'rel="canonical"' not in html:
|
||||
html = html.replace("</head>", f"{seo_tags}\n</head>", 1)
|
||||
if '<div id="root"></div>' in html:
|
||||
html = html.replace(
|
||||
'<div id="root"></div>',
|
||||
f'<div id="root"><noscript>{crawl_nav}</noscript></div>',
|
||||
)
|
||||
index_path.write_text(html, encoding="utf-8")
|
||||
|
||||
def _build_crawl_nav_html(self, nav: dict) -> str:
|
||||
"""Static nav links for non-JS crawlers."""
|
||||
links: list[str] = []
|
||||
for page in self._collect_nav_pages(nav):
|
||||
path = page["path"].rstrip("/") or "/"
|
||||
title = self._escape_html(page["title"])
|
||||
url = self._escape_html(self._absolute_url(path))
|
||||
links.append(f'<li><a href="{url}">{title}</a></li>')
|
||||
|
||||
if not links:
|
||||
return ""
|
||||
|
||||
return (
|
||||
'<nav aria-label="Documentation">'
|
||||
f'<ul>{"".join(links)}</ul>'
|
||||
"</nav>"
|
||||
)
|
||||
|
||||
def _build_seo_tags(self, path: str, title: str, description: str, *, noindex: bool = False) -> str:
|
||||
"""Build canonical, OG, and Twitter meta tags."""
|
||||
d = self._escape_html(description)
|
||||
p = self._escape_html(path)
|
||||
canonical = self._escape_html(self._absolute_url(path))
|
||||
og_url = canonical
|
||||
|
||||
# Extract original page title by removing the site title suffix if present
|
||||
site_title = self.config.site.title
|
||||
@@ -646,15 +915,19 @@ class Compiler:
|
||||
t = self._escape_html(title)
|
||||
|
||||
tags = [
|
||||
f' <link rel="canonical" href="{p}" />',
|
||||
f' <link rel="canonical" href="{canonical}" />',
|
||||
f' <meta property="og:title" content="{t}" />',
|
||||
f' <meta property="og:description" content="{d}" />',
|
||||
f' <meta property="og:url" content="{p}" />',
|
||||
f' <meta property="og:url" content="{og_url}" />',
|
||||
' <meta property="og:type" content="article" />',
|
||||
]
|
||||
|
||||
# Add default OG image if configured
|
||||
if self.config.seo and self.config.seo.default_image:
|
||||
og_image = self._escape_html(self.config.seo.default_image)
|
||||
image = self.config.seo.default_image
|
||||
if not image.startswith(("http://", "https://")):
|
||||
image = self._absolute_url(image)
|
||||
og_image = self._escape_html(image)
|
||||
tags.append(f' <meta property="og:image" content="{og_image}" />')
|
||||
|
||||
# Add Twitter card tags
|
||||
@@ -667,8 +940,34 @@ class Compiler:
|
||||
handle = self._escape_html(self.config.seo.twitter["handle"])
|
||||
tags.append(f' <meta name="twitter:site" content="{handle}" />')
|
||||
|
||||
robots = self.config.seo.robots if self.config.seo else None
|
||||
if noindex:
|
||||
tags.append(' <meta name="robots" content="noindex, nofollow" />')
|
||||
elif robots:
|
||||
index = "index" if robots.get("index", True) else "noindex"
|
||||
follow = "follow" if robots.get("follow", True) else "nofollow"
|
||||
tags.append(f' <meta name="robots" content="{index}, {follow}" />')
|
||||
|
||||
return "\n".join(tags)
|
||||
|
||||
def _build_json_ld(self, path: str, title: str, description: str) -> str:
|
||||
url = self._absolute_url(path)
|
||||
site_url = self._site_url()
|
||||
payload: dict = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "TechArticle",
|
||||
"headline": title,
|
||||
"description": description,
|
||||
"url": url,
|
||||
}
|
||||
if site_url:
|
||||
payload["isPartOf"] = {
|
||||
"@type": "WebSite",
|
||||
"name": self.config.site.title,
|
||||
"url": f"{site_url}/",
|
||||
}
|
||||
return f' <script type="application/ld+json">{json.dumps(payload, ensure_ascii=False)}</script>'
|
||||
|
||||
def _get_noscript_content(self, output_dir: Path, path: str) -> str:
|
||||
"""Load markdown file and convert to simple HTML for noscript block."""
|
||||
# Try to find the corresponding markdown file
|
||||
@@ -736,14 +1035,36 @@ class Compiler:
|
||||
# List items
|
||||
if re.match(r"^[-*+]\s+", stripped):
|
||||
text = re.sub(r"^[-*+]\s+", "", stripped)
|
||||
result.append(f"<li>{text}</li>")
|
||||
result.append(f"<li>{Compiler._inline_md_to_html(text)}</li>")
|
||||
continue
|
||||
|
||||
# Paragraphs
|
||||
result.append(f"<p>{stripped}</p>")
|
||||
result.append(f"<p>{Compiler._inline_md_to_html(stripped)}</p>")
|
||||
|
||||
return "\n".join(result)
|
||||
|
||||
@staticmethod
|
||||
def _inline_md_to_html(text: str) -> str:
|
||||
"""Convert inline markdown (links, emphasis) for noscript HTML."""
|
||||
escaped = (
|
||||
text.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
)
|
||||
|
||||
def link_repl(match: re.Match[str]) -> str:
|
||||
label = match.group(1)
|
||||
href = match.group(2)
|
||||
if href.startswith("/") or href.startswith("http://") or href.startswith("https://"):
|
||||
safe_href = href.replace('"', """)
|
||||
return f'<a href="{safe_href}">{label}</a>'
|
||||
return label
|
||||
|
||||
escaped = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", link_repl, escaped)
|
||||
escaped = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", escaped)
|
||||
escaped = re.sub(r"`([^`]+)`", r"<code>\1</code>", escaped)
|
||||
return escaped
|
||||
|
||||
@staticmethod
|
||||
def _escape_html(text: str) -> str:
|
||||
"""Escape HTML special characters for meta tags."""
|
||||
|
||||
@@ -15,6 +15,8 @@ class DocPage:
|
||||
slug: str
|
||||
title: str
|
||||
order: int = 0
|
||||
description: str = ""
|
||||
noindex: bool = False
|
||||
frontmatter: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -82,6 +84,10 @@ class DocsScanner:
|
||||
|
||||
# Determine title (from frontmatter or first heading)
|
||||
title = frontmatter.get("title") or self._extract_title(content) or slug
|
||||
description = str(frontmatter.get("description", "") or "").strip()
|
||||
if not description:
|
||||
description = self._extract_description(content)
|
||||
noindex = bool(frontmatter.get("noindex", False))
|
||||
|
||||
# Extract order from frontmatter or filename
|
||||
order = frontmatter.get("order", 0)
|
||||
@@ -96,9 +102,42 @@ class DocsScanner:
|
||||
slug=slug,
|
||||
title=title,
|
||||
order=order,
|
||||
description=description,
|
||||
noindex=noindex,
|
||||
frontmatter=frontmatter,
|
||||
)
|
||||
|
||||
def _extract_description(self, content: str) -> str:
|
||||
"""First paragraph after frontmatter — used for meta description when omitted."""
|
||||
if content.startswith("---"):
|
||||
end_idx = content.find("---", 3)
|
||||
if end_idx != -1:
|
||||
content = content[end_idx + 3 :]
|
||||
|
||||
in_code = False
|
||||
for line in content.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("```"):
|
||||
in_code = not in_code
|
||||
continue
|
||||
if in_code or not stripped:
|
||||
continue
|
||||
if stripped.startswith("#"):
|
||||
continue
|
||||
if stripped.startswith(("-", "*", "+", "|", ">", "!", "---")):
|
||||
continue
|
||||
|
||||
text = stripped
|
||||
text = re.sub(r"!\[([^\]]*)\]\([^)]+\)", r"\1", text)
|
||||
text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
|
||||
text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
|
||||
text = re.sub(r"\*([^*]+)\*", r"\1", text)
|
||||
text = re.sub(r"`([^`]+)`", r"\1", text)
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
if len(text) >= 20:
|
||||
return text[:160].rstrip()
|
||||
return ""
|
||||
|
||||
def _path_to_slug(self, file_path: Path) -> str:
|
||||
"""Convert a file path to a URL slug."""
|
||||
rel_path = file_path.relative_to(self.docs_dir)
|
||||
|
||||
@@ -13,6 +13,8 @@ class NavItem:
|
||||
|
||||
title: str
|
||||
path: str
|
||||
description: str = ""
|
||||
noindex: bool = False
|
||||
children: list["NavItem"] = field(default_factory=list)
|
||||
|
||||
|
||||
@@ -38,7 +40,12 @@ class NavBuilder:
|
||||
full_path = f"{self.base_path}/{page.slug}".rstrip("/")
|
||||
|
||||
# Create NavItem for this page
|
||||
item = NavItem(title=page.title, path=full_path)
|
||||
item = NavItem(
|
||||
title=page.title,
|
||||
path=full_path,
|
||||
description=page.description,
|
||||
noindex=page.noindex,
|
||||
)
|
||||
|
||||
# Determine parent path
|
||||
parts = page.slug.split("/")
|
||||
@@ -76,6 +83,10 @@ class NavBuilder:
|
||||
def _item_to_dict(self, item: NavItem) -> dict:
|
||||
"""Convert a NavItem to dictionary."""
|
||||
result = {"title": item.title, "path": item.path}
|
||||
if item.description:
|
||||
result["description"] = item.description
|
||||
if item.noindex:
|
||||
result["noindex"] = True
|
||||
if item.children:
|
||||
result["children"] = [self._item_to_dict(child) for child in item.children]
|
||||
return result
|
||||
@@ -83,6 +83,7 @@ def auto_register_defaults() -> None:
|
||||
(".workflows", "WorkflowsFeature"),
|
||||
(".guardrails", "GuardrailsFeature"),
|
||||
(".eval", "EvalFeature"),
|
||||
(".training", "TrainingFeature"),
|
||||
(".telemetry", "TelemetryFeature"),
|
||||
(".tracing", "TracingFeature"),
|
||||
(".security", "SecurityFeature"),
|
||||
|
||||
@@ -462,6 +462,24 @@ async def _run_and_broadcast(
|
||||
collected_tool_calls: Dict[str, Dict] = {} # Merge STARTED+COMPLETED per tool_call_id
|
||||
collected_elements: List[Dict[str, Any]] = []
|
||||
|
||||
# Register this run so ``abort_run(run_id)`` can cancel it, and announce the
|
||||
# run to WS clients up-front. Without this the ``_active_runs`` registry was
|
||||
# never populated, so the Stop button and ``POST /api/chat/abort`` were
|
||||
# silent no-ops that always returned ``no_active_run`` (issue #274). The
|
||||
# ``run_started`` event also lets the frontend capture ``run_id`` and enable
|
||||
# the Stop button immediately, before the first token arrives.
|
||||
self_task = asyncio.current_task()
|
||||
if self_task is not None:
|
||||
mgr._active_runs[run_id] = self_task
|
||||
started_event: Dict[str, Any] = {
|
||||
"type": "run_started",
|
||||
"session_id": session_id,
|
||||
"run_id": run_id,
|
||||
}
|
||||
if agent_name:
|
||||
started_event["agent_name"] = agent_name
|
||||
await mgr.broadcast(session_id, started_event)
|
||||
|
||||
try:
|
||||
# Pass image attachments to provider for SDK native multimodal handling
|
||||
run_kwargs = {}
|
||||
@@ -644,6 +662,9 @@ async def _run_and_broadcast(
|
||||
"error": str(e),
|
||||
},
|
||||
)
|
||||
finally:
|
||||
# Always deregister the run, on normal completion, error, or abort.
|
||||
mgr._active_runs.pop(run_id, None)
|
||||
|
||||
# Save assistant response
|
||||
if full_response or collected_elements:
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
"""Training Lab feature — read-mostly proxy for praisonai-train sessions.
|
||||
|
||||
Architecture:
|
||||
TrainingFeature (BaseFeatureProtocol)
|
||||
├── reads JSON session files from ~/.praison/train/*.json
|
||||
└── optionally delegates apply to praisonai_train.train.agents.apply_training
|
||||
|
||||
The praisonai-train package is an optional dependency. When it is not installed
|
||||
the feature degrades gracefully: session listing/detail still work from the raw
|
||||
JSON storage, while apply returns a 503 with an install hint.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
from ._base import BaseFeatureProtocol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_EARLY_STOP_THRESHOLD = 9.5
|
||||
|
||||
|
||||
def _storage_dir() -> Path:
|
||||
"""Resolve the praisonai-train JSON storage directory."""
|
||||
override = os.environ.get("PRAISON_TRAIN_DIR")
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
return Path.home() / ".praison" / "train"
|
||||
|
||||
|
||||
def _train_available() -> bool:
|
||||
"""Return True when the praisonai-train package can be imported."""
|
||||
import importlib.util
|
||||
|
||||
return importlib.util.find_spec("praisonai_train") is not None
|
||||
|
||||
|
||||
def _train_version() -> str | None:
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
|
||||
return version("praisonai-train")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _iter_scores(iterations: list[dict[str, Any]]) -> list[float]:
|
||||
scores: list[float] = []
|
||||
for it in iterations:
|
||||
score = it.get("score")
|
||||
if isinstance(score, (int, float)):
|
||||
scores.append(float(score))
|
||||
return scores
|
||||
|
||||
|
||||
def _best_iteration_index(iterations: list[dict[str, Any]]) -> int | None:
|
||||
best_idx: int | None = None
|
||||
best_score = float("-inf")
|
||||
for idx, it in enumerate(iterations):
|
||||
score = it.get("score")
|
||||
if isinstance(score, (int, float)) and float(score) > best_score:
|
||||
best_score = float(score)
|
||||
best_idx = idx
|
||||
return best_idx
|
||||
|
||||
|
||||
def _summarize_session(session_id: str, path: Path, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Build a session summary row (parity with praisonai-train list)."""
|
||||
report = data.get("report") or {}
|
||||
metadata = report.get("metadata") or data.get("metadata") or {}
|
||||
iterations = data.get("iterations") or report.get("iterations") or []
|
||||
|
||||
completed = int(report.get("total_iterations", len(iterations)) or 0)
|
||||
requested = metadata.get("target_iterations")
|
||||
requested = int(requested) if isinstance(requested, (int, float)) else completed
|
||||
mode = metadata.get("mode", "llm")
|
||||
agent_id = metadata.get("agent_id") or data.get("agent_id")
|
||||
|
||||
scores = _iter_scores(iterations)
|
||||
avg_score = report.get("avg_score")
|
||||
if avg_score is None and scores:
|
||||
avg_score = sum(scores) / len(scores)
|
||||
improvement = report.get("improvement")
|
||||
if improvement is None and len(scores) >= 2:
|
||||
improvement = scores[-1] - scores[0]
|
||||
passed = report.get("passed")
|
||||
|
||||
early_stopped = bool(requested > completed and mode == "llm")
|
||||
|
||||
try:
|
||||
stat = path.stat()
|
||||
size_bytes = stat.st_size
|
||||
modified_at = _iso_from_mtime(stat.st_mtime)
|
||||
except OSError:
|
||||
size_bytes = 0
|
||||
modified_at = None
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"agent_id": agent_id,
|
||||
"completed_iterations": completed,
|
||||
"requested_iterations": requested,
|
||||
"early_stopped": early_stopped,
|
||||
"avg_score": round(float(avg_score), 4) if avg_score is not None else None,
|
||||
"improvement": round(float(improvement), 4) if improvement is not None else None,
|
||||
"passed": passed,
|
||||
"status_label": _status_label(passed, completed),
|
||||
"mode": mode,
|
||||
"size_bytes": size_bytes,
|
||||
"modified_at": modified_at,
|
||||
}
|
||||
|
||||
|
||||
def _iso_from_mtime(mtime: float) -> str:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return datetime.fromtimestamp(mtime, tz=timezone.utc).replace(microsecond=0).isoformat()
|
||||
|
||||
|
||||
def _status_label(passed: Any, completed: int) -> str:
|
||||
if completed == 0:
|
||||
return "INCOMPLETE"
|
||||
if passed is True:
|
||||
return "PASSED"
|
||||
if passed is False:
|
||||
return "NEEDS WORK"
|
||||
return "UNKNOWN"
|
||||
|
||||
|
||||
def _load_session_file(path: Path) -> dict[str, Any]:
|
||||
with path.open("r", encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
class TrainingFeature(BaseFeatureProtocol):
|
||||
"""Training Lab — inspect praisonai-train agent sessions and apply profiles."""
|
||||
|
||||
feature_name = "training"
|
||||
feature_description = "Inspect praisonai-train agent sessions and apply training profiles"
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.feature_name
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return self.feature_description
|
||||
|
||||
def routes(self) -> list[Route]:
|
||||
return [
|
||||
Route("/api/training/status", self._status, methods=["GET"]),
|
||||
Route("/api/training/sessions", self._list_sessions, methods=["GET"]),
|
||||
Route("/api/training/sessions/{session_id}", self._get_session, methods=["GET"]),
|
||||
Route("/api/training/sessions/{session_id}/apply", self._apply, methods=["POST"]),
|
||||
]
|
||||
|
||||
async def health(self) -> dict[str, Any]:
|
||||
available = _train_available()
|
||||
return {
|
||||
"status": "ok" if available else "degraded",
|
||||
"feature": self.name,
|
||||
"train_package_available": available,
|
||||
}
|
||||
|
||||
# ── storage helpers ──────────────────────────────────────────────
|
||||
|
||||
def _read_sessions(self, storage: Path) -> list[dict[str, Any]]:
|
||||
if not storage.exists():
|
||||
return []
|
||||
summaries: list[dict[str, Any]] = []
|
||||
for path in storage.glob("*.json"):
|
||||
session_id = path.stem
|
||||
try:
|
||||
data = _load_session_file(path)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
logger.warning("Skipping unreadable training session: %s", path)
|
||||
continue
|
||||
summaries.append(_summarize_session(session_id, path, data))
|
||||
summaries.sort(key=lambda s: s.get("modified_at") or "", reverse=True)
|
||||
return summaries
|
||||
|
||||
# ── route handlers ───────────────────────────────────────────────
|
||||
|
||||
async def _status(self, request: Request) -> JSONResponse:
|
||||
storage = _storage_dir()
|
||||
available = _train_available()
|
||||
sessions = self._read_sessions(storage)
|
||||
warnings: list[str] = []
|
||||
if not available:
|
||||
warnings.append(
|
||||
"praisonai-train not installed. Install with: pip install praisonai-train"
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "ok" if available else "degraded",
|
||||
"feature": self.name,
|
||||
"train_package_available": available,
|
||||
"train_package_version": _train_version(),
|
||||
"storage_dir": str(storage),
|
||||
"storage_backend": "json",
|
||||
"session_count": len(sessions),
|
||||
"sqlite_supported": False,
|
||||
"warnings": warnings,
|
||||
}
|
||||
)
|
||||
|
||||
async def _list_sessions(self, request: Request) -> JSONResponse:
|
||||
storage = _storage_dir()
|
||||
try:
|
||||
limit = int(request.query_params.get("limit", "50"))
|
||||
except ValueError:
|
||||
limit = 50
|
||||
agent_id = request.query_params.get("agent_id")
|
||||
|
||||
sessions = self._read_sessions(storage)
|
||||
if agent_id:
|
||||
sessions = [s for s in sessions if s.get("agent_id") == agent_id]
|
||||
limited = sessions[: max(limit, 0)]
|
||||
return JSONResponse({"sessions": limited, "count": len(limited)})
|
||||
|
||||
async def _get_session(self, request: Request) -> JSONResponse:
|
||||
session_id = request.path_params["session_id"]
|
||||
storage = _storage_dir()
|
||||
path = storage / f"{session_id}.json"
|
||||
if not path.exists():
|
||||
return JSONResponse(
|
||||
{"error": f"Session not found: {session_id}"}, status_code=404
|
||||
)
|
||||
try:
|
||||
data = _load_session_file(path)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return JSONResponse(
|
||||
{"error": f"Corrupt session JSON: {session_id}"}, status_code=422
|
||||
)
|
||||
|
||||
report = data.get("report") or {}
|
||||
iterations = list(data.get("iterations") or report.get("iterations") or [])
|
||||
best_idx = _best_iteration_index(iterations)
|
||||
for idx, it in enumerate(iterations):
|
||||
it["is_best"] = idx == best_idx
|
||||
|
||||
summary = _summarize_session(session_id, path, data)
|
||||
best_iteration = iterations[best_idx] if best_idx is not None else None
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"session_id": session_id,
|
||||
"report": report,
|
||||
"iterations": iterations,
|
||||
"best_iteration": best_iteration,
|
||||
"best_iteration_num": (
|
||||
best_iteration.get("iteration_num", best_idx + 1)
|
||||
if best_iteration is not None
|
||||
else None
|
||||
),
|
||||
"early_stopped": summary["early_stopped"],
|
||||
"early_stop_reason": (
|
||||
"score_threshold"
|
||||
if summary["early_stopped"]
|
||||
and _iter_scores(iterations)
|
||||
and _iter_scores(iterations)[-1] >= _EARLY_STOP_THRESHOLD
|
||||
else None
|
||||
),
|
||||
"requested_iterations": summary["requested_iterations"],
|
||||
"completed_iterations": summary["completed_iterations"],
|
||||
"avg_score": summary["avg_score"],
|
||||
"improvement": summary["improvement"],
|
||||
"passed": summary["passed"],
|
||||
"mode": summary["mode"],
|
||||
"scenarios": data.get("scenarios") or [],
|
||||
}
|
||||
)
|
||||
|
||||
async def _apply(self, request: Request) -> JSONResponse:
|
||||
session_id = request.path_params["session_id"]
|
||||
storage = _storage_dir()
|
||||
path = storage / f"{session_id}.json"
|
||||
if not path.exists():
|
||||
return JSONResponse(
|
||||
{"error": f"Session not found: {session_id}"}, status_code=404
|
||||
)
|
||||
|
||||
if not _train_available():
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "praisonai-train is not installed",
|
||||
"hint": "pip install praisonai-train",
|
||||
},
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
agent_id = body.get("agent_id")
|
||||
if not agent_id:
|
||||
return JSONResponse({"error": "agent_id is required"}, status_code=422)
|
||||
iteration = body.get("iteration")
|
||||
|
||||
from .agents import get_agent_registry
|
||||
|
||||
registry = get_agent_registry()
|
||||
agent_def = registry.get(agent_id)
|
||||
if agent_def is None:
|
||||
return JSONResponse(
|
||||
{"error": f"Agent not found: {agent_id}"}, status_code=404
|
||||
)
|
||||
|
||||
try:
|
||||
result = self._apply_training(agent_def, session_id, iteration)
|
||||
except Exception as exc:
|
||||
logger.exception("apply_training failed for session %s", session_id)
|
||||
return JSONResponse({"error": str(exc)}, status_code=500)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"session_id": session_id,
|
||||
"agent_id": agent_id,
|
||||
"iteration_applied": result.get("iteration_applied"),
|
||||
"quality_score": result.get("quality_score"),
|
||||
"suggestions_count": result.get("suggestions_count", 0),
|
||||
"hook_id": result.get("hook_id", f"training_hook_{session_id}"),
|
||||
"message": f"Training profile applied to agent '{agent_id}'",
|
||||
}
|
||||
)
|
||||
|
||||
def _apply_training(
|
||||
self, agent_def: dict[str, Any], session_id: str, iteration: int | None
|
||||
) -> dict[str, Any]:
|
||||
from praisonai_train.train.agents import apply_training # type: ignore
|
||||
from praisonaiagents import Agent # type: ignore
|
||||
|
||||
agent = Agent(
|
||||
name=agent_def.get("name", agent_def.get("id", "agent")),
|
||||
instructions=agent_def.get("instructions", agent_def.get("description", "")),
|
||||
llm=agent_def.get("model"),
|
||||
)
|
||||
apply_training(agent, session_id=session_id, iteration=iteration)
|
||||
hook_id = getattr(agent, "_training_hook_id", f"training_hook_{session_id}")
|
||||
|
||||
storage = _storage_dir()
|
||||
applied_iteration = iteration
|
||||
quality_score = None
|
||||
suggestions_count = 0
|
||||
try:
|
||||
data = _load_session_file(storage / f"{session_id}.json")
|
||||
iterations = list(data.get("iterations") or [])
|
||||
idx = _best_iteration_index(iterations) if iteration is None else None
|
||||
chosen = None
|
||||
if idx is not None:
|
||||
chosen = iterations[idx]
|
||||
applied_iteration = chosen.get("iteration_num", idx + 1)
|
||||
elif iteration is not None:
|
||||
for it in iterations:
|
||||
if it.get("iteration_num") == iteration:
|
||||
chosen = it
|
||||
break
|
||||
if chosen is not None:
|
||||
quality_score = chosen.get("score")
|
||||
suggestions_count = len(chosen.get("suggestions") or [])
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
return {
|
||||
"iteration_applied": applied_iteration,
|
||||
"quality_score": quality_score,
|
||||
"suggestions_count": suggestions_count,
|
||||
"hook_id": hook_id,
|
||||
}
|
||||
@@ -187,9 +187,11 @@ class A11yConfig(BaseModel):
|
||||
class SEOConfig(BaseModel):
|
||||
"""SEO configuration."""
|
||||
|
||||
title_template: str = Field(default="%s", alias="titleTemplate")
|
||||
title_template: str = Field(default="%s | %s", alias="titleTemplate")
|
||||
default_image: Optional[str] = Field(default=None, alias="defaultImage")
|
||||
twitter: Optional[dict[str, str]] = None
|
||||
site_url: Optional[str] = Field(default=None, alias="siteUrl")
|
||||
robots: Optional[dict[str, bool]] = None
|
||||
|
||||
|
||||
class DependenciesConfig(BaseModel):
|
||||
|
||||
@@ -687,12 +687,21 @@ def _build_html(style: str) -> str:
|
||||
title = f"{_site_title} Dashboard" if style == "dashboard" else _site_title
|
||||
cache_bust = int(_server_start_time)
|
||||
|
||||
# Anti-flicker: dark background + hide React content until plugins render
|
||||
# Anti-flicker: hide React content until plugins render (uses theme CSS vars)
|
||||
anti_flicker = ""
|
||||
theme_bootstrap = (
|
||||
'<script id="aiui-theme-bootstrap">'
|
||||
"(function(){try{var t=localStorage.getItem('aiui-theme-preference');"
|
||||
"if(t==='dark'||(t==='system'&&matchMedia('(prefers-color-scheme: dark)').matches))"
|
||||
"document.documentElement.classList.add('dark');"
|
||||
"else if(t==='light')document.documentElement.classList.remove('dark');"
|
||||
"}catch(e){}})();"
|
||||
"</script>"
|
||||
)
|
||||
if style == "docs":
|
||||
anti_flicker = (
|
||||
'<style id="aiui-anti-flicker">'
|
||||
"html,body{background:#0f172a!important;color:#e2e8f0}"
|
||||
"html,body{background:hsl(var(--background))!important;color:hsl(var(--foreground))}"
|
||||
"#root>*{opacity:0;transition:opacity .15s ease}"
|
||||
"</style>"
|
||||
)
|
||||
@@ -751,16 +760,26 @@ def _build_html(style: str) -> str:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
canonical_script = (
|
||||
'<script id="aiui-canonical-url">'
|
||||
"(function(){var p=location.pathname.replace(/\\/+$/, '')||'/';"
|
||||
"if(p!==location.pathname){history.replaceState(null,'',"
|
||||
"p+location.search+location.hash);}})();"
|
||||
"</script>"
|
||||
)
|
||||
|
||||
return (
|
||||
'<!doctype html><html lang="en" style="background:#0f172a;color:#e2e8f0"><head>'
|
||||
'<!doctype html><html lang="en"><head>'
|
||||
'<meta charset="UTF-8">'
|
||||
'<meta name="viewport" content="width=device-width,initial-scale=1.0">'
|
||||
f"{canonical_script}"
|
||||
f"{theme_bootstrap}"
|
||||
f'<link rel="stylesheet" href="/assets/index.css?v={cache_bust}">'
|
||||
f"{anti_flicker}"
|
||||
f"{theme_vars_tag}"
|
||||
f"{custom_css_tag}"
|
||||
f"<title>{title}</title>"
|
||||
'</head><body style="background:#0f172a;margin:0">'
|
||||
'</head><body style="margin:0">'
|
||||
'<div id="root"></div>'
|
||||
f"{react_script}"
|
||||
f'<script src="/plugins/plugin-loader.js?v={cache_bust}"></script>'
|
||||
@@ -813,10 +832,8 @@ async def _plugins_config(request: Request) -> JSONResponse:
|
||||
"syntax-highlight",
|
||||
"code-copy",
|
||||
"content-loader",
|
||||
"toc",
|
||||
"mermaid",
|
||||
"nav-intercept",
|
||||
"mkdocs-compat",
|
||||
"homepage",
|
||||
]
|
||||
else:
|
||||
@@ -3320,6 +3337,14 @@ def create_app(
|
||||
"description": "Manage conversation sessions",
|
||||
"order": 20,
|
||||
},
|
||||
{
|
||||
"id": "runs",
|
||||
"title": "Runs",
|
||||
"icon": "⏱️",
|
||||
"group": "Control",
|
||||
"description": "Run flight recorder — timeline debugger",
|
||||
"order": 22,
|
||||
},
|
||||
{
|
||||
"id": "usage",
|
||||
"title": "Usage",
|
||||
@@ -3496,6 +3521,14 @@ def create_app(
|
||||
"description": "Agent evaluation & accuracy",
|
||||
"order": 40,
|
||||
},
|
||||
{
|
||||
"id": "training",
|
||||
"title": "Training",
|
||||
"icon": "🧪",
|
||||
"group": "Control",
|
||||
"description": "Inspect praisonai-train sessions & apply profiles",
|
||||
"order": 42,
|
||||
},
|
||||
{
|
||||
"id": "code-studio",
|
||||
"title": "Code Studio",
|
||||
@@ -3548,6 +3581,7 @@ def create_app(
|
||||
]
|
||||
_page_api_overrides = {
|
||||
"sessions": "/sessions",
|
||||
"runs": "/api/sessions",
|
||||
"cron": "/api/schedules",
|
||||
"channels": "/api/channels",
|
||||
"mcp": "/api/mcp/servers",
|
||||
@@ -3587,6 +3621,8 @@ def create_app(
|
||||
# Always mount built-in frontend plugins and assets BEFORE catch-all /
|
||||
_frontend_dir = Path(__file__).parent / "templates" / "frontend"
|
||||
_plugins_dir = _frontend_dir / "plugins"
|
||||
if static_dir and (Path(static_dir) / "plugins").is_dir():
|
||||
_plugins_dir = Path(static_dir) / "plugins"
|
||||
_assets_dir = _frontend_dir / "assets"
|
||||
|
||||
# Dynamic plugins.json route — MUST come before static /plugins mount
|
||||
@@ -3613,6 +3649,13 @@ def create_app(
|
||||
routes.append(Route("/", _serve_index, methods=["GET"]))
|
||||
|
||||
_static_app = StaticFiles(directory=str(static_dir), html=True)
|
||||
_static_root = Path(static_dir)
|
||||
|
||||
async def _serve_static_file(scope, receive, send, file_path: Path, media_type: str | None = None):
|
||||
from starlette.responses import FileResponse
|
||||
|
||||
response = FileResponse(str(file_path), media_type=media_type)
|
||||
await response(scope, receive, send)
|
||||
|
||||
async def _http_only_static(scope, receive, send):
|
||||
if scope["type"] != "http":
|
||||
@@ -3621,7 +3664,25 @@ def create_app(
|
||||
await send({"type": "websocket.close", "code": 1008})
|
||||
return
|
||||
|
||||
# Try StaticFiles first; on 404, fall back to SPA index
|
||||
request_path = scope.get("path", "")
|
||||
rel = request_path.lstrip("/")
|
||||
if rel:
|
||||
exact = _static_root / rel
|
||||
if exact.is_file():
|
||||
await _serve_static_file(scope, receive, send, exact)
|
||||
return
|
||||
if not request_path.endswith("/"):
|
||||
html_path = _static_root / f"{rel}.html"
|
||||
if html_path.is_file():
|
||||
await _serve_static_file(scope, receive, send, html_path, media_type="text/html")
|
||||
return
|
||||
if exact.is_dir():
|
||||
index = exact / "index.html"
|
||||
if index.is_file():
|
||||
await _serve_static_file(scope, receive, send, index, media_type="text/html")
|
||||
return
|
||||
|
||||
# Try StaticFiles; on 404, fall back to SPA index
|
||||
# (paths like /chat, /memory don't have static files but need
|
||||
# the SPA shell so JS routing can take over).
|
||||
response_started = False
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
import{g as p,r as u,d as a}from"./chunk-SVP7TREG.js";import{p as f}from"./chunk-JWPE2WC7.js";import{_ as n,l as o}from"./mermaid.core.js";import{M as c,b as d}from"./cynefin-OW5HDTMX.js";import"./index.js";var v=d().RailroadAbnf.parser.LangiumParser,i=n(e=>{const r=e.alternatives.map(g);return r.length===1?r[0]:{type:"choice",alternatives:r}},"transformAlternation"),g=n(e=>{const r=e.elements.map(y);return r.length===1?r[0]:{type:"sequence",elements:r}},"transformConcatenation"),b=n(e=>{if(e.includes("*")){const[t,s]=e.split("*"),l=t?parseInt(t,10):0,m=s?parseInt(s,10):1/0;return{min:l,max:m}}const r=parseInt(e,10);return{min:r,max:r}},"parseRepeat"),y=n(e=>{const r=A(e.primary);if(!e.repeat)return r;const{min:t,max:s}=b(e.repeat);return t===0&&s===1?{type:"optional",element:r}:{type:"repetition",element:r,min:t,max:s}},"transformElement"),A=n(e=>{switch(e.$type){case"AbnfStringLiteral":return{type:"terminal",value:e.value};case"AbnfNumVal":return{type:"terminal",value:e.value};case"AbnfRuleName":return{type:"nonterminal",name:e.name};case"AbnfGroup":return i(e.element);case"AbnfOptionalGroup":return{type:"optional",element:i(e.element)};default:throw new Error(`Unsupported ABNF primary node: ${e.$type}`)}},"transformPrimary"),P=n(e=>({name:e.name,definition:i(e.definition)}),"transformRule"),h=n(e=>{f(e,a),e.title&&a.setTitle(e.title),e.rules.map(r=>a.addRule(P(r)))},"populateDb"),R={parse:n(e=>{a.clear(),o.debug("[ABNF Parser] Starting Langium parse");const r=v.parse(e);if(r.lexerErrors.length>0||r.parserErrors.length>0)throw new c(r);const t=r.value;o.debug("[ABNF Parser] Parsed rules:",t.rules.length),h(t),o.debug("[ABNF Parser] Parse complete")},"parse"),parser:{yy:a}},B={parser:R,db:a,renderer:u,styles:p};export{B as diagram};
|
||||
@@ -0,0 +1 @@
|
||||
import{a0 as ln,a1 as an,a2 as H,a3 as q,a4 as B,a5 as un,a6 as y,a7 as tn,a8 as L,a9 as _,aa as rn,ab as o,ac as on,ad as sn,ae as fn}from"./mermaid.core.js";function cn(l){return l.innerRadius}function yn(l){return l.outerRadius}function gn(l){return l.startAngle}function dn(l){return l.endAngle}function mn(l){return l&&l.padAngle}function pn(l,h,I,D,v,A,C,a){var O=I-l,i=D-h,n=C-v,d=a-A,u=d*O-n*i;if(!(u*u<y))return u=(n*(h-A)-d*(l-v))/u,[l+u*O,h+u*i]}function W(l,h,I,D,v,A,C){var a=l-I,O=h-D,i=(C?A:-A)/L(a*a+O*O),n=i*O,d=-i*a,u=l+n,s=h+d,f=I+n,c=D+d,F=(u+f)/2,t=(s+c)/2,m=f-u,g=c-s,R=m*m+g*g,T=v-A,P=u*c-f*s,S=(g<0?-1:1)*L(on(0,T*T*R-P*P)),j=(P*g-m*S)/R,z=(-P*m-g*S)/R,w=(P*g+m*S)/R,p=(-P*m+g*S)/R,x=j-F,e=z-t,r=w-F,G=p-t;return x*x+e*e>r*r+G*G&&(j=w,z=p),{cx:j,cy:z,x01:-n,y01:-d,x11:j*(v/T-1),y11:z*(v/T-1)}}function hn(){var l=cn,h=yn,I=B(0),D=null,v=gn,A=dn,C=mn,a=null,O=ln(i);function i(){var n,d,u=+l.apply(this,arguments),s=+h.apply(this,arguments),f=v.apply(this,arguments)-un,c=A.apply(this,arguments)-un,F=rn(c-f),t=c>f;if(a||(a=n=O()),s<u&&(d=s,s=u,u=d),!(s>y))a.moveTo(0,0);else if(F>tn-y)a.moveTo(s*H(f),s*q(f)),a.arc(0,0,s,f,c,!t),u>y&&(a.moveTo(u*H(c),u*q(c)),a.arc(0,0,u,c,f,t));else{var m=f,g=c,R=f,T=c,P=F,S=F,j=C.apply(this,arguments)/2,z=j>y&&(D?+D.apply(this,arguments):L(u*u+s*s)),w=_(rn(s-u)/2,+I.apply(this,arguments)),p=w,x=w,e,r;if(z>y){var G=sn(z/u*q(j)),M=sn(z/s*q(j));(P-=G*2)>y?(G*=t?1:-1,R+=G,T-=G):(P=0,R=T=(f+c)/2),(S-=M*2)>y?(M*=t?1:-1,m+=M,g-=M):(S=0,m=g=(f+c)/2)}var J=s*H(m),K=s*q(m),N=u*H(T),Q=u*q(T);if(w>y){var U=s*H(g),V=s*q(g),X=u*H(R),Y=u*q(R),E;if(F<an)if(E=pn(J,K,X,Y,U,V,N,Q)){var Z=J-E[0],$=K-E[1],b=U-E[0],k=V-E[1],nn=1/q(fn((Z*b+$*k)/(L(Z*Z+$*$)*L(b*b+k*k)))/2),en=L(E[0]*E[0]+E[1]*E[1]);p=_(w,(u-en)/(nn-1)),x=_(w,(s-en)/(nn+1))}else p=x=0}S>y?x>y?(e=W(X,Y,J,K,s,x,t),r=W(U,V,N,Q,s,x,t),a.moveTo(e.cx+e.x01,e.cy+e.y01),x<w?a.arc(e.cx,e.cy,x,o(e.y01,e.x01),o(r.y01,r.x01),!t):(a.arc(e.cx,e.cy,x,o(e.y01,e.x01),o(e.y11,e.x11),!t),a.arc(0,0,s,o(e.cy+e.y11,e.cx+e.x11),o(r.cy+r.y11,r.cx+r.x11),!t),a.arc(r.cx,r.cy,x,o(r.y11,r.x11),o(r.y01,r.x01),!t))):(a.moveTo(J,K),a.arc(0,0,s,m,g,!t)):a.moveTo(J,K),!(u>y)||!(P>y)?a.lineTo(N,Q):p>y?(e=W(N,Q,U,V,u,-p,t),r=W(J,K,X,Y,u,-p,t),a.lineTo(e.cx+e.x01,e.cy+e.y01),p<w?a.arc(e.cx,e.cy,p,o(e.y01,e.x01),o(r.y01,r.x01),!t):(a.arc(e.cx,e.cy,p,o(e.y01,e.x01),o(e.y11,e.x11),!t),a.arc(0,0,u,o(e.cy+e.y11,e.cx+e.x11),o(r.cy+r.y11,r.cx+r.x11),t),a.arc(r.cx,r.cy,p,o(r.y11,r.x11),o(r.y01,r.x01),!t))):a.arc(0,0,u,T,R,t)}if(a.closePath(),n)return a=null,n+""||null}return i.centroid=function(){var n=(+l.apply(this,arguments)+ +h.apply(this,arguments))/2,d=(+v.apply(this,arguments)+ +A.apply(this,arguments))/2-an/2;return[H(d)*n,q(d)*n]},i.innerRadius=function(n){return arguments.length?(l=typeof n=="function"?n:B(+n),i):l},i.outerRadius=function(n){return arguments.length?(h=typeof n=="function"?n:B(+n),i):h},i.cornerRadius=function(n){return arguments.length?(I=typeof n=="function"?n:B(+n),i):I},i.padRadius=function(n){return arguments.length?(D=n==null?null:typeof n=="function"?n:B(+n),i):D},i.startAngle=function(n){return arguments.length?(v=typeof n=="function"?n:B(+n),i):v},i.endAngle=function(n){return arguments.length?(A=typeof n=="function"?n:B(+n),i):A},i.padAngle=function(n){return arguments.length?(C=typeof n=="function"?n:B(+n),i):C},i.context=function(n){return arguments.length?(a=n??null,i):a},i}export{hn as d};
|
||||
@@ -0,0 +1,36 @@
|
||||
import{p as qe}from"./chunk-JWPE2WC7.js";import{_ as ct,D as Qe,Y as Je,l as Fe,b as Ke,a as je,n as _e,o as tr,g as er,s as rr,p as ir,A as ar,y as nr,B as or,c as Ee,b5 as Te,ak as pe,i as sr,j as hr,q as lr,al as fr,b6 as cr}from"./mermaid.core.js";import{p as gr}from"./cynefin-OW5HDTMX.js";import{c as be}from"./cytoscape.esm.js";import{g as ur}from"./index.js";var he={exports:{}},le={exports:{}},fe={exports:{}},dr=fe.exports,De;function vr(){return De||(De=1,(function(x,M){(function(R,T){x.exports=T()})(dr,function(){return(function(w){var R={};function T(v){if(R[v])return R[v].exports;var l=R[v]={i:v,l:!1,exports:{}};return w[v].call(l.exports,l,l.exports,T),l.l=!0,l.exports}return T.m=w,T.c=R,T.i=function(v){return v},T.d=function(v,l,i){T.o(v,l)||Object.defineProperty(v,l,{configurable:!1,enumerable:!0,get:i})},T.n=function(v){var l=v&&v.__esModule?function(){return v.default}:function(){return v};return T.d(l,"a",l),l},T.o=function(v,l){return Object.prototype.hasOwnProperty.call(v,l)},T.p="",T(T.s=28)})([(function(w,R,T){function v(){}v.QUALITY=1,v.DEFAULT_CREATE_BENDS_AS_NEEDED=!1,v.DEFAULT_INCREMENTAL=!1,v.DEFAULT_ANIMATION_ON_LAYOUT=!0,v.DEFAULT_ANIMATION_DURING_LAYOUT=!1,v.DEFAULT_ANIMATION_PERIOD=50,v.DEFAULT_UNIFORM_LEAF_NODE_SIZES=!1,v.DEFAULT_GRAPH_MARGIN=15,v.NODE_DIMENSIONS_INCLUDE_LABELS=!1,v.SIMPLE_NODE_SIZE=40,v.SIMPLE_NODE_HALF_SIZE=v.SIMPLE_NODE_SIZE/2,v.EMPTY_COMPOUND_NODE_SIZE=40,v.MIN_EDGE_LENGTH=1,v.WORLD_BOUNDARY=1e6,v.INITIAL_WORLD_BOUNDARY=v.WORLD_BOUNDARY/1e3,v.WORLD_CENTER_X=1200,v.WORLD_CENTER_Y=900,w.exports=v}),(function(w,R,T){var v=T(2),l=T(8),i=T(9);function r(f,e,d){v.call(this,d),this.isOverlapingSourceAndTarget=!1,this.vGraphObject=d,this.bendpoints=[],this.source=f,this.target=e}r.prototype=Object.create(v.prototype);for(var a in v)r[a]=v[a];r.prototype.getSource=function(){return this.source},r.prototype.getTarget=function(){return this.target},r.prototype.isInterGraph=function(){return this.isInterGraph},r.prototype.getLength=function(){return this.length},r.prototype.isOverlapingSourceAndTarget=function(){return this.isOverlapingSourceAndTarget},r.prototype.getBendpoints=function(){return this.bendpoints},r.prototype.getLca=function(){return this.lca},r.prototype.getSourceInLca=function(){return this.sourceInLca},r.prototype.getTargetInLca=function(){return this.targetInLca},r.prototype.getOtherEnd=function(f){if(this.source===f)return this.target;if(this.target===f)return this.source;throw"Node is not incident with this edge"},r.prototype.getOtherEndInGraph=function(f,e){for(var d=this.getOtherEnd(f),t=e.getGraphManager().getRoot();;){if(d.getOwner()==e)return d;if(d.getOwner()==t)break;d=d.getOwner().getParent()}return null},r.prototype.updateLength=function(){var f=new Array(4);this.isOverlapingSourceAndTarget=l.getIntersection(this.target.getRect(),this.source.getRect(),f),this.isOverlapingSourceAndTarget||(this.lengthX=f[0]-f[2],this.lengthY=f[1]-f[3],Math.abs(this.lengthX)<1&&(this.lengthX=i.sign(this.lengthX)),Math.abs(this.lengthY)<1&&(this.lengthY=i.sign(this.lengthY)),this.length=Math.sqrt(this.lengthX*this.lengthX+this.lengthY*this.lengthY))},r.prototype.updateLengthSimple=function(){this.lengthX=this.target.getCenterX()-this.source.getCenterX(),this.lengthY=this.target.getCenterY()-this.source.getCenterY(),Math.abs(this.lengthX)<1&&(this.lengthX=i.sign(this.lengthX)),Math.abs(this.lengthY)<1&&(this.lengthY=i.sign(this.lengthY)),this.length=Math.sqrt(this.lengthX*this.lengthX+this.lengthY*this.lengthY)},w.exports=r}),(function(w,R,T){function v(l){this.vGraphObject=l}w.exports=v}),(function(w,R,T){var v=T(2),l=T(10),i=T(13),r=T(0),a=T(16),f=T(5);function e(t,s,o,c){o==null&&c==null&&(c=s),v.call(this,c),t.graphManager!=null&&(t=t.graphManager),this.estimatedSize=l.MIN_VALUE,this.inclusionTreeDepth=l.MAX_VALUE,this.vGraphObject=c,this.edges=[],this.graphManager=t,o!=null&&s!=null?this.rect=new i(s.x,s.y,o.width,o.height):this.rect=new i}e.prototype=Object.create(v.prototype);for(var d in v)e[d]=v[d];e.prototype.getEdges=function(){return this.edges},e.prototype.getChild=function(){return this.child},e.prototype.getOwner=function(){return this.owner},e.prototype.getWidth=function(){return this.rect.width},e.prototype.setWidth=function(t){this.rect.width=t},e.prototype.getHeight=function(){return this.rect.height},e.prototype.setHeight=function(t){this.rect.height=t},e.prototype.getCenterX=function(){return this.rect.x+this.rect.width/2},e.prototype.getCenterY=function(){return this.rect.y+this.rect.height/2},e.prototype.getCenter=function(){return new f(this.rect.x+this.rect.width/2,this.rect.y+this.rect.height/2)},e.prototype.getLocation=function(){return new f(this.rect.x,this.rect.y)},e.prototype.getRect=function(){return this.rect},e.prototype.getDiagonal=function(){return Math.sqrt(this.rect.width*this.rect.width+this.rect.height*this.rect.height)},e.prototype.getHalfTheDiagonal=function(){return Math.sqrt(this.rect.height*this.rect.height+this.reLine truncated
|
||||
.edge {
|
||||
stroke-width: ${x.archEdgeWidth};
|
||||
stroke: ${x.archEdgeColor};
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
fill: ${x.archEdgeArrowColor};
|
||||
}
|
||||
|
||||
.node-bkg {
|
||||
fill: none;
|
||||
stroke: ${x.archGroupBorderColor};
|
||||
stroke-width: ${x.archGroupBorderWidth};
|
||||
stroke-dasharray: 8;
|
||||
}
|
||||
.node-icon-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.node-icon-text > div {
|
||||
color: #fff;
|
||||
margin: 1px;
|
||||
height: fit-content;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
`,"getStyles"),Fr=Sr;function me(x,M){if(x===0)return M();const w=Math.random;let R=x>>>0;Math.random=function(){R=R+1831565813>>>0;let T=R;return T=Math.imul(T^T>>>15,T|1),T^=T+Math.imul(T^T>>>7,T|61),((T^T>>>14)>>>0)/4294967296};try{return M()}finally{Math.random=w}}ct(me,"withSeededRandom");var re=ct(x=>`<g><rect width="80" height="80" style="fill: #087ebf; stroke-width: 0px;"/>${x}</g>`,"wrapIcon"),ne={prefix:"mermaid-architecture",height:80,width:80,icons:{database:{body:re('<path id="b" data-name="4" d="m20,57.86c0,3.94,8.95,7.14,20,7.14s20-3.2,20-7.14" style="fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;"/><path id="c" data-name="3" d="m20,45.95c0,3.94,8.95,7.14,20,7.14s20-3.2,20-7.14" style="fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;"/><path id="d" data-name="2" d="m20,34.05c0,3.94,8.95,7.14,20,7.14s20-3.2,20-7.14" style="fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;"/><ellipse id="e" data-name="1" cx="40" cy="22.14" rx="20" ry="7.14" style="fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;"/><line x1="20" y1="57.86" x2="20" y2="22.14" style="fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;"/><line x1="60" y1="57.86" x2="60" y2="22.14" style="fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;"/>')},server:{body:re('<rect x="17.5" y="17.5" width="45" height="45" rx="2" ry="2" style="fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;"/><line x1="17.5" y1="32.5" x2="62.5" y2="32.5" style="fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;"/><line x1="17.5" y1="47.5" x2="62.5" y2="47.5" style="fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;"/><g><path d="m56.25,25c0,.27-.45.5-1,.5h-10.5c-.55,0-1-.23-1-.5s.45-.5,1-.5h10.5c.55,0,1,.23,1,.5Z" style="fill: #fff; stroke-width: 0px;"/><path d="m56.25,25c0,.27-.45.5-1,.5h-10.5c-.55,0-1-.23-1-.5s.45-.5,1-.5h10.5c.55,0,1,.23,1,.5Z" style="fill: none; stroke: #fff; stroke-miterlimit: 10;"/></g><g><path d="m56.25,40c0,.27-.45.5-1,.5h-10.5c-.55,0-1-.23-1-.5s.45-.5,1-.5h10.5c.55,0,1,.23,1,.5Z" style="fill: #fff; stroke-width: 0px;"/><path d="m56.25,40c0,.27-.45.5-1,.5h-10.5c-.55,0-1-.23-1-.5s.45-.5,1-.5h10.5c.55,0,1,.23,1,.5Z" style="fill: none; stroke: #fff; stroke-miterlimit: 10;"/></g><g><path d="m56.25,55c0,.27-.45.5-1,.5h-10.5c-.55,0-1-.23-1-.5s.45-.5,1-.5h10.5c.55,0,1,.23,1,.5Z" style="fill: #fff; stroke-width: 0px;"/><path d="m56.25,55c0,.27-.45.5-1,.5h-10.5c-.55,0-1-.23-1-.5s.45-.5,1-.5h10.5c.55,0,1,.23,1,.5Z" style="fill: none; stroke: #fff; stroke-miterlimit: 10;"/></g><g><circle cx="32.5" cy="25" r=".75" style="fill: #fff; stroke: #fff; stroke-miterlimit: 10;"/><circle cx="27.5" cy="25" r=".75" style="fill: #fff; stroke: #fff; stroke-miterlimit: 10;"/><circle cx="22.5" cy="25" r=".75" style="fill: #fff; stroke: #fff; stroke-miterlimit: 10;"/></g><g><circle cx="32.5" cy="40" r=".75" style="fill: #fff; stroke: #fff; stroke-miterlimit: 10;"/><circle cx="27.5" cy="40" r=".75" style="fill: #fff; stroke: #fff; stroke-miterlimit: 10;"/><circle cx="22.5" cy="40" r=".75" style="fill: #fff; stroke: #fff; stroke-miterlimit: 10;"/></g><g><circle cx="32.5" cy="55" r=".75" style="fill: #fff; stroke: #fff; stroke-miterlimit: 10;"/><circle cx="27.5" cy="55" r=".75" style="fill: #fff; stroke: #fff; stroke-miterlimit: 10;"/><circle cx="22.5" cy="55" r=".75" style="fill: #fff; stroke: #fff; stroke-miterlimit: 10;"/></g>')},disk:{body:re('<rect x="20" y="15" width="40" height="50" rx="1" ry="1" style="fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;"/><ellipse cx="24" cy="19.17" rx=".8" ry=".83" style="fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;"/><ellipse cx="56" cy="19.17" rx=".8" ry=".83" style="fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;"/><ellipse cx="24" cy="60.83" rx=".8" ry=".83" style="fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;"/><ellipse cx="56" cy="60.83" rx=".8" ry=".83" style="fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;"/><ellipse cx="40" cy="33.75" rx="14" ry="14.58" style="fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;"/><ellipse cx="40" cy="33.75" rx="4" ry="4.17" style="fill: #fff; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;"/><path d="m37.51,42.52l-4.83,13.22c-.26.71-1.1,1.02-1.76.64l-4.18-2.42c-.66-.38-.81-1.26-.33-1.84l9.01-10.8c.88-1.05,2.56-.08,2.09,1.2Z" style="fill: #fff; stroke-width: 0px;"/>')},internet:{body:re('<circle cx="40" cy="40" r="22.5" style="fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;"/><line x1="40" y1="17.5" x2="40" y2="62.5" style="fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;"/><line x1="17.5" y1="40" x2="62.5" y2="40" style="fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;"/><path d="m39.99,17.51c-15.28,11.1-15.28,33.88,0,44.98" style="fill: none; sLine truncated
|
||||
translate(${L}, ${F-E.height/2})
|
||||
translate(${p*y.width/2}, ${m*y.height/2})
|
||||
rotate(${-1*p*m*45}, 0, ${E.height/2})
|
||||
`)}}}}}))},"drawEdges"),Pr=ct(async function(x,M,w,R){const v=w.getConfigField("padding")*.75,l=w.getConfigField("fontSize"),r=w.getConfigField("iconSize")/2;await Promise.all(M.nodes().map(async a=>{const f=ie(a);if(f.type==="group"){const{h:e,w:d,x1:t,y1:s}=a.boundingBox(),o=x.append("rect");o.attr("id",`${R}-group-${f.id}`).attr("x",t+r).attr("y",s+r).attr("width",d).attr("height",e).attr("class","node-bkg");const c=x.append("g");let h=t,N=s;if(f.icon){const g=c.append("g");g.html(`<g>${await pe(f.icon,{height:v,width:v,fallbackPrefix:ne.prefix})}</g>`),g.attr("transform","translate("+(h+r+1)+", "+(N+r+1)+")"),h+=v,N+=l/2-1-2}if(f.label){const g=c.append("g");await Te(g,f.label,{useHtmlLabels:!1,width:d,classes:"architecture-service-label"},Ee()),g.attr("dy","1em").attr("alignment-baseline","middle").attr("dominant-baseline","start").attr("text-anchor","start"),g.attr("transform","translate("+(h+r+4)+", "+(N+r+2)+")")}w.setElementForId(f.id,o)}}))},"drawGroups"),Gr=ct(async function(x,M,w,R){const T=Ee();for(const v of w){const l=M.append("g"),i=x.getConfigField("iconSize");if(v.title){const e=l.append("g");await Te(e,v.title,{useHtmlLabels:!1,width:i*1.5,classes:"architecture-service-label"},T),e.attr("dy","1em").attr("alignment-baseline","middle").attr("dominant-baseline","middle").attr("text-anchor","middle"),e.attr("transform","translate("+i/2+", "+i+")")}const r=l.append("g");if(v.icon)r.html(`<g>${await pe(v.icon,{height:i,width:i,fallbackPrefix:ne.prefix})}</g>`);else if(v.iconText){r.html(`<g>${await pe("blank",{height:i,width:i,fallbackPrefix:ne.prefix})}</g>`);const t=r.append("g").append("foreignObject").attr("width",i).attr("height",i).append("div").attr("class","node-icon-text").attr("style",`height: ${i}px;`).append("div").html(sr(v.iconText,T)),s=parseInt(window.getComputedStyle(t.node(),null).getPropertyValue("font-size").replace(/\D/g,""))??16;t.attr("style",`-webkit-line-clamp: ${Math.floor((i-2)/s)};`)}else r.append("path").attr("class","node-bkg").attr("id",`${R}-node-${v.id}`).attr("d",`M0,${i} V5 Q0,0 5,0 H${i-5} Q${i},0 ${i},5 V${i} Z`);l.attr("id",`${R}-service-${v.id}`).attr("class","architecture-service");const{width:a,height:f}=l.node().getBBox();v.width=a,v.height=f,x.setElementForId(v.id,l)}return 0},"drawServices"),Ur=ct(function(x,M,w,R){w.forEach(T=>{const v=M.append("g"),l=x.getConfigField("iconSize");v.append("g").append("rect").attr("id",`${R}-node-${T.id}`).attr("fill-opacity","0").attr("width",l).attr("height",l),v.attr("class","architecture-junction");const{width:r,height:a}=v._groups[0][0].getBBox();v.width=r,v.height=a,x.setElementForId(T.id,v)})},"drawJunctions");fr([{name:ne.prefix,icons:ne}]);be.use(Nr);function Xe(x,M,w){x.forEach(R=>{M.add({group:"nodes",data:{type:"service",id:R.id,icon:R.icon,label:R.title,parent:R.in,width:w.getConfigField("iconSize"),height:w.getConfigField("iconSize")},classes:"node-service"})})}ct(Xe,"addServices");function He(x,M,w){x.forEach(R=>{M.add({group:"nodes",data:{type:"junction",id:R.id,parent:R.in,width:w.getConfigField("iconSize"),height:w.getConfigField("iconSize")},classes:"node-junction"})})}ct(He,"addJunctions");function We(x,M){M.nodes().map(w=>{const R=ie(w);if(R.type==="group")return;R.x=w.position().x,R.y=w.position().y,x.getElementById(R.id).attr("transform","translate("+(R.x||0)+","+(R.y||0)+")")})}ct(We,"positionNodes");function Ve(x,M){x.forEach(w=>{M.add({group:"nodes",data:{type:"group",id:w.id,icon:w.icon,label:w.title,parent:w.in},classes:"node-group"})})}ct(Ve,"addGroups");function $e(x,M){x.forEach(w=>{const{lhsId:R,rhsId:T,lhsInto:v,lhsGroup:l,rhsInto:i,lhsDir:r,rhsDir:a,rhsGroup:f,title:e}=w,d=Ne(w.lhsDir,w.rhsDir)?"segments":"straight",t={id:`${R}-${T}`,label:e,source:R,sourceDir:r,sourceArrow:v,sourceGroup:l,sourceEndpoint:r==="L"?"0 50%":r==="R"?"100% 50%":r==="T"?"50% 0":"50% 100%",target:T,targetDir:a,targetArrow:i,targetGroup:f,targetEndpoint:a==="L"?"0 50%":a==="R"?"100% 50%":a==="T"?"50% 0":"50% 100%"};M.add({group:"edges",data:t,classes:d})})}ct($e,"addEdges");function ze(x,M,w,R=[]){const T=ct((d,t)=>{const s=new Map;for(const[o,c]of d.entries()){const h=`${o}`;let N=0;const g=[...c.entries()];if(g.length===1){s.set(h,g[0][1]);continue}for(let u=0;u<g.length-1;u++)for(let L=u+1;L<g.length;L++){const[F,C]=g[u],[G,V]=g[L];if(w.get(Pe(F,G))===t)s.set(h,[...s.get(h)??[],...C,...V]);else if(F==="default"||G==="default")s.set(h,[...s.get(h)??[],...C,...V]);else{const $=`${h}-${N++}`;s.set($,C);const A=`${h}-${N++}`;s.set(A,V)}}}return s},"flattenAlignments"),v=M.map(d=>{const t=new Map,s=new Map;return d.forEach(([o,c],h)=>{const N=x.getNode(h)?.in??"default",g=t.get(c)??new Map;t.has(c)||t.set(c,g);const u=s.get(o)??new Map;s.has(o)||s.set(o,u);for(const L of[g,u]){const F=L.get(N)??[];L.has(N)||L.set(N,F),F.push(h)}}),{horiz:[...T(t,"horizontal").values()].filter(o=>o.length>1),vert:[...T(s,"vertical").values()].filter(o=>o.length>1)}}),[l,i]=v.reduce(([d,t],{horiz:s,vert:o})=>[[...d,Line truncated
|
||||
@@ -0,0 +1,129 @@
|
||||
import{g as Pe}from"./chunk-5VM5RSS4.js";import{aD as Fe,aE as xe,aF as Me,aG as Ke,aH as Ye,aI as We,aJ as Ve,aK as Ue,aL as He,aM as Xe,aN as je,aO as Ge,aP as qe,aQ as Je,aR as Ze,aS as Qe,aT as $e,aU as et,aV as tt,aW as st,aX as rt,aY as it,aZ as at,a_ as nt,a$ as ot,_ as u,y as Y,j as X,b0 as ct,d as lt,l as m,p as ut,r as gt,c as Le,Q as ht,az as dt,ay as pt,aA as ft,k as bt,b1 as St,ag as ye,ah as xt}from"./mermaid.core.js";import{c as Lt}from"./channel.js";import"./index.js";function yt(e){return Array.isArray(e)}function mt(e){if(Fe(e))return e;const a=xe(e);if(!wt(e))return{};if(yt(e)){const r=Array.from(e);return e.length>0&&typeof e[0]=="string"&&Object.hasOwn(e,"index")&&(r.index=e.index,r.input=e.input),r}if(Me(e)){const r=e,l=r.constructor;return new l(r.buffer,r.byteOffset,r.length)}if(a==="[object ArrayBuffer]")return new ArrayBuffer(e.byteLength);if(a==="[object DataView]"){const r=e,l=r.buffer,d=r.byteOffset,s=r.byteLength,h=new ArrayBuffer(s),x=new Uint8Array(l,d,s);return new Uint8Array(h).set(x),new DataView(h)}if(a==="[object Boolean]"||a==="[object Number]"||a==="[object String]"){const r=e.constructor,l=new r(e.valueOf());return a==="[object String]"?_t(l,e):se(l,e),l}if(a==="[object Date]")return new Date(Number(e));if(a==="[object RegExp]"){const r=e,l=new RegExp(r.source,r.flags);return l.lastIndex=r.lastIndex,l}if(a==="[object Symbol]")return Object(Symbol.prototype.valueOf.call(e));if(a==="[object Map]"){const r=e,l=new Map;return r.forEach((d,s)=>{l.set(s,d)}),l}if(a==="[object Set]"){const r=e,l=new Set;return r.forEach(d=>{l.add(d)}),l}if(a==="[object Arguments]"){const r=e,l={};return se(l,r),l.length=r.length,l[Symbol.iterator]=r[Symbol.iterator],l}const o={};return kt(o,e),se(o,e),Et(o,e),o}function wt(e){switch(xe(e)){case ot:case nt:case at:case it:case rt:case st:case tt:case et:case $e:case Qe:case Ze:case Je:case qe:case Ge:case je:case Xe:case He:case Ue:case Ve:case We:case Ye:case Ke:return!0;default:return!1}}function se(e,a){for(const o in a)Object.hasOwn(a,o)&&(e[o]=a[o])}function Et(e,a){const o=Object.getOwnPropertySymbols(a);for(let r=0;r<o.length;r++){const l=o[r];Object.prototype.propertyIsEnumerable.call(a,l)&&(e[l]=a[l])}}function _t(e,a){const o=a.valueOf().length;for(const r in a)Object.hasOwn(a,r)&&(Number.isNaN(Number(r))||Number(r)>=o)&&(e[r]=a[r])}function kt(e,a){const o=Object.getPrototypeOf(a);o!==null&&typeof a.constructor=="function"&&Object.setPrototypeOf(e,o)}var ie=(function(){var e=u(function(v,g,i,c){for(i=i||{},c=v.length;c--;i[v[c]]=g);return i},"o"),a=[1,15],o=[1,7],r=[1,13],l=[1,14],d=[1,19],s=[1,16],h=[1,17],x=[1,18],y=[8,30],S=[8,10,21,28,29,30,31,39,43,46],f=[1,23],L=[1,24],w=[8,10,15,16,21,28,29,30,31,39,43,46],b=[8,10,15,16,21,27,28,29,30,31,39,43,46],k=[1,49],_={trace:u(function(){},"trace"),yy:{},symbols_:{error:2,spaceLines:3,SPACELINE:4,NL:5,separator:6,SPACE:7,EOF:8,start:9,BLOCK_DIAGRAM_KEY:10,document:11,stop:12,statement:13,link:14,LINK:15,START_LINK:16,LINK_LABEL:17,STR:18,nodeStatement:19,columnsStatement:20,SPACE_BLOCK:21,blockStatement:22,classDefStatement:23,cssClassStatement:24,styleStatement:25,node:26,SIZE:27,COLUMNS:28,"id-block":29,end:30,NODE_ID:31,nodeShapeNLabel:32,dirList:33,DIR:34,NODE_DSTART:35,NODE_DEND:36,BLOCK_ARROW_START:37,BLOCK_ARROW_END:38,classDef:39,CLASSDEF_ID:40,CLASSDEF_STYLEOPTS:41,DEFAULT:42,class:43,CLASSENTITY_IDS:44,STYLECLASS:45,style:46,STYLE_ENTITY_IDS:47,STYLE_DEFINITION_DATA:48,$accept:0,$end:1},terminals_:{2:"error",4:"SPACELINE",5:"NL",7:"SPACE",8:"EOF",10:"BLOCK_DIAGRAM_KEY",15:"LINK",16:"START_LINK",17:"LINK_LABEL",18:"STR",21:"SPACE_BLOCK",27:"SIZE",28:"COLUMNS",29:"id-block",30:"end",31:"NODE_ID",34:"DIR",35:"NODE_DSTART",36:"NODE_DEND",37:"BLOCK_ARROW_START",38:"BLOCK_ARROW_END",39:"classDef",40:"CLASSDEF_ID",41:"CLASSDEF_STYLEOPTS",42:"DEFAULT",43:"class",44:"CLASSENTITY_IDS",45:"STYLECLASS",46:"style",47:"STYLE_ENTITY_IDS",48:"STYLE_DEFINITION_DATA"},productions_:[0,[3,1],[3,2],[3,2],[6,1],[6,1],[6,1],[9,3],[12,1],[12,1],[12,2],[12,2],[11,1],[11,2],[14,1],[14,4],[13,1],[13,1],[13,1],[13,1],[13,1],[13,1],[13,1],[19,3],[19,2],[19,1],[20,1],[22,4],[22,3],[26,1],[26,2],[33,1],[33,2],[32,3],[32,4],[23,3],[23,3],[24,3],[25,3]],performAction:u(function(g,i,c,p,E,t,M){var n=t.length-1;switch(E){case 4:p.getLogger().debug("Rule: separator (NL) ");break;case 5:p.getLogger().debug("Rule: separator (Space) ");break;case 6:p.getLogger().debug("Rule: separator (EOF) ");break;case 7:p.getLogger().debug("Rule: hierarchy: ",t[n-1]),p.setHierarchy(t[n-1]);break;case 8:p.getLogger().debug("Stop NL ");break;case 9:p.getLogger().debug("Stop EOF ");break;case 10:p.getLogger().debug("Stop NL2 ");break;case 11:p.getLogger().debug("Stop EOF2 ");break;case 12:p.getLogger().debug("Rule: statement: ",t[n]),typeof t[n].length=="number"?this.$=t[n]:this.$=[t[n]];break;case 13:p.getLogger().debug("Rule: statement #2: ",t[n-1]),this.$=[t[n-1]].concat(t[n]);break;case 14:p.getLogger().debug("Rule: link: ",Line truncated
|
||||
`+D.showPosition()+`
|
||||
Expecting `+H.join(", ")+", got '"+(this.terminals_[N]||N)+"'":te="Parse error on line "+(P+1)+": Unexpected "+(N==V?"end of input":"'"+(this.terminals_[N]||N)+"'"),this.parseError(te,{text:D.match,token:this.terminals_[N]||N,line:D.yylineno,loc:$,expected:H})}if(C[0]instanceof Array&&C.length>1)throw new Error("Parse Error: multiple actions possible at state: "+R+", token: "+N);switch(C[0]){case 1:c.push(N),E.push(D.yytext),t.push(D.yylloc),c.push(C[1]),N=null,W=D.yyleng,n=D.yytext,P=D.yylineno,$=D.yylloc;break;case 2:if(B=this.productions_[C[1]][1],F.$=E[E.length-B],F._$={first_line:t[t.length-(B||1)].first_line,last_line:t[t.length-1].last_line,first_column:t[t.length-(B||1)].first_column,last_column:t[t.length-1].last_column},Be&&(F._$.range=[t[t.length-(B||1)].range[0],t[t.length-1].range[1]]),ee=this.performAction.apply(F,[n,W,P,A.yy,C[1],E,t].concat(Z)),typeof ee<"u")return ee;B&&(c=c.slice(0,-1*B*2),E=E.slice(0,-1*B),t=t.slice(0,-1*B)),c.push(this.productions_[C[1]][0]),E.push(F.$),t.push(F._$),pe=M[c[c.length-2]][c[c.length-1]],c.push(pe);break;case 3:return!0}}return!0},"parse")},T=(function(){var v={EOF:1,parseError:u(function(i,c){if(this.yy.parser)this.yy.parser.parseError(i,c);else throw new Error(i)},"parseError"),setInput:u(function(g,i){return this.yy=i||this.yy||{},this._input=g,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:u(function(){var g=this._input[0];this.yytext+=g,this.yyleng++,this.offset++,this.match+=g,this.matched+=g;var i=g.match(/(?:\r\n?|\n).*/g);return i?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),g},"input"),unput:u(function(g){var i=g.length,c=g.split(/(?:\r\n?|\n)/g);this._input=g+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-i),this.offset-=i;var p=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),c.length-1&&(this.yylineno-=c.length-1);var E=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:c?(c.length===p.length?this.yylloc.first_column:0)+p[p.length-c.length].length-c[0].length:this.yylloc.first_column-i},this.options.ranges&&(this.yylloc.range=[E[0],E[0]+this.yyleng-i]),this.yyleng=this.yytext.length,this},"unput"),more:u(function(){return this._more=!0,this},"more"),reject:u(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true).
|
||||
`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:u(function(g){this.unput(this.match.slice(g))},"less"),pastInput:u(function(){var g=this.matched.substr(0,this.matched.length-this.match.length);return(g.length>20?"...":"")+g.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:u(function(){var g=this.match;return g.length<20&&(g+=this._input.substr(0,20-g.length)),(g.substr(0,20)+(g.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:u(function(){var g=this.pastInput(),i=new Array(g.length+1).join("-");return g+this.upcomingInput()+`
|
||||
`+i+"^"},"showPosition"),test_match:u(function(g,i){var c,p,E;if(this.options.backtrack_lexer&&(E={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(E.yylloc.range=this.yylloc.range.slice(0))),p=g[0].match(/(?:\r\n?|\n).*/g),p&&(this.yylineno+=p.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:p?p[p.length-1].length-p[p.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+g[0].length},this.yytext+=g[0],this.match+=g[0],this.matches=g,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(g[0].length),this.matched+=g[0],c=this.performAction.call(this,this.yy,this,i,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),c)return c;if(this._backtrack){for(var t in E)this[t]=E[t];return!1}return!1},"test_match"),next:u(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var g,i,c,p;this._more||(this.yytext="",this.match="");for(var E=this._currentRules(),t=0;t<E.length;t++)if(c=this._input.match(this.rules[E[t]]),c&&(!i||c[0].length>i[0].length)){if(i=c,p=t,this.options.backtrack_lexer){if(g=this.test_match(c,E[t]),g!==!1)return g;if(this._backtrack){i=!1;continue}else return!1}else if(!this.options.flex)break}return i?(g=this.test_match(i,E[p]),g!==!1?g:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text.
|
||||
`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:u(function(){var i=this.next();return i||this.lex()},"lex"),begin:u(function(i){this.conditionStack.push(i)},"begin"),popState:u(function(){var i=this.conditionStack.length-1;return i>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:u(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:u(function(i){return i=this.conditionStack.length-1-Math.abs(i||0),i>=0?this.conditionStack[i]:"INITIAL"},"topState"),pushState:u(function(i){this.begin(i)},"pushState"),stateStackSize:u(function(){return this.conditionStack.length},"stateStackSize"),options:{},performAction:u(function(i,c,p,E){switch(p){case 0:return i.getLogger().debug("Found block-beta"),10;case 1:return i.getLogger().debug("Found id-block"),29;case 2:return i.getLogger().debug("Found block"),10;case 3:i.getLogger().debug(".",c.yytext);break;case 4:i.getLogger().debug("_",c.yytext);break;case 5:return 5;case 6:return c.yytext=-1,28;case 7:return c.yytext=c.yytext.replace(/columns\s+/,""),i.getLogger().debug("COLUMNS (LEX)",c.yytext),28;case 8:this.pushState("md_string");break;case 9:return"MD_STR";case 10:this.popState();break;case 11:this.pushState("string");break;case 12:i.getLogger().debug("LEX: POPPING STR:",c.yytext),this.popState();break;case 13:return i.getLogger().debug("LEX: STR end:",c.yytext),"STR";case 14:return c.yytext=c.yytext.replace(/space\:/,""),i.getLogger().debug("SPACE NUM (LEX)",c.yytext),21;case 15:return c.yytext="1",i.getLogger().debug("COLUMNS (LEX)",c.yytext),21;case 16:return 42;case 17:return"LINKSTYLE";case 18:return"INTERPOLATE";case 19:return this.pushState("CLASSDEF"),39;case 20:return this.popState(),this.pushState("CLASSDEFID"),"DEFAULT_CLASSDEF_ID";case 21:return this.popState(),this.pushState("CLASSDEFID"),40;case 22:return this.popState(),41;case 23:return this.pushState("CLASS"),43;case 24:return this.popState(),this.pushState("CLASS_STYLE"),44;case 25:return this.popState(),45;case 26:return this.pushState("STYLE_STMNT"),46;case 27:return this.popState(),this.pushState("STYLE_DEFINITION"),47;case 28:return this.popState(),48;case 29:return this.pushState("acc_title"),"acc_title";case 30:return this.popState(),"acc_title_value";case 31:return this.pushState("acc_descr"),"acc_descr";case 32:return this.popState(),"acc_descr_value";case 33:this.pushState("acc_descr_multiline");break;case 34:this.popState();break;case 35:return"acc_descr_multiline_value";case 36:return 30;case 37:return this.popState(),i.getLogger().debug("Lex: (("),"NODE_DEND";case 38:return this.popState(),i.getLogger().debug("Lex: (("),"NODE_DEND";case 39:return this.popState(),i.getLogger().debug("Lex: ))"),"NODE_DEND";case 40:return this.popState(),i.getLogger().debug("Lex: (("),"NODE_DEND";case 41:return this.popState(),i.getLogger().debug("Lex: (("),"NODE_DEND";case 42:return this.popState(),i.getLogger().debug("Lex: (-"),"NODE_DEND";case 43:return this.popState(),i.getLogger().debug("Lex: -)"),"NODE_DEND";case 44:return this.popState(),i.getLogger().debug("Lex: (("),"NODE_DEND";case 45:return this.popState(),i.getLogger().debug("Lex: ]]"),"NODE_DEND";case 46:return this.popState(),i.getLogger().debug("Lex: ("),"NODE_DEND";case 47:return this.popState(),i.getLogger().debug("Lex: ])"),"NODE_DEND";case 48:return this.popState(),i.getLogger().debug("Lex: /]"),"NODE_DEND";case 49:return this.popState(),i.getLogger().debug("Lex: /]"),"NODE_DEND";case 50:return this.popState(),i.getLogger().debug("Lex: )]"),"NODE_DEND";case 51:return this.popState(),i.getLogger().debug("Lex: )"),"NODE_DEND";case 52:return this.popState(),i.getLogger().debug("Lex: ]>"),"NODE_DEND";case 53:return this.popState(),i.getLogger().debug("Lex: ]"),"NODE_DEND";case 54:return i.getLogger().debug("Lexa: -)"),this.pushState("NODE"),35;case 55:return i.getLogger().debug("Lexa: (-"),this.pushState("NODE"),35;case 56:return i.getLogger().debug("Lexa: ))"),this.pushState("NODE"),35;case 57:return i.getLogger().debug("Lexa: )"),this.pushState("NODE"),35;case 58:return i.getLogger().debug("Lex: ((("),this.pushState("NODE"),35;case 59:return i.getLogger().debug("Lexa: )"),this.pushState("NODE"),35;case 60:return i.getLogger().debug("Lexa: )"),this.pushState("NODE"),35;case 61:return i.getLogger().debug("Lexa: )"),this.pushState("NODE"),35;case 62:return i.getLogger().debug("Lexc: >"),this.pushState("NODE"),35;case 63:return i.getLogger().debug("Lexa: (["),this.pushState("NODE"),35;case 64:return i.getLogger().debug("Lexa: )"),this.pushState("NODE"),35;case 65:return this.pushState("NODE"),35;case 66:return this.pushState("NODE"),35;case 67:return this.pushState("NODE"),35;case 68:return this.pushState("NODE"),35;case 69:return this.pushState("NODE"),35;case 70:return this.pushState("NODE"),35;case 71:return this.p
Line truncated
|
||||
font-family: ${e.fontFamily};
|
||||
color: ${e.nodeTextColor||e.textColor};
|
||||
}
|
||||
.cluster-label text {
|
||||
fill: ${e.titleColor};
|
||||
}
|
||||
.cluster-label span {
|
||||
color: ${e.titleColor};
|
||||
}
|
||||
|
||||
|
||||
|
||||
.label text,span {
|
||||
fill: ${e.nodeTextColor||e.textColor};
|
||||
color: ${e.nodeTextColor||e.textColor};
|
||||
}
|
||||
|
||||
.node rect,
|
||||
.node circle,
|
||||
.node ellipse,
|
||||
.node polygon,
|
||||
.node path {
|
||||
fill: ${e.mainBkg};
|
||||
stroke: ${e.nodeBorder};
|
||||
stroke-width: 1px;
|
||||
}
|
||||
.flowchart-label text {
|
||||
text-anchor: middle;
|
||||
}
|
||||
// .flowchart-label .text-outer-tspan {
|
||||
// text-anchor: middle;
|
||||
// }
|
||||
// .flowchart-label .text-inner-tspan {
|
||||
// text-anchor: start;
|
||||
// }
|
||||
|
||||
.node .label {
|
||||
text-align: center;
|
||||
}
|
||||
.node.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.arrowheadPath {
|
||||
fill: ${e.arrowheadColor};
|
||||
}
|
||||
|
||||
.edgePaths .path {
|
||||
stroke: ${e.lineColor};
|
||||
stroke-width: 2.0px;
|
||||
}
|
||||
|
||||
.flowchart-link {
|
||||
stroke: ${e.lineColor};
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.edgeLabel {
|
||||
background-color: ${e.edgeLabelBackground};
|
||||
/*
|
||||
* This is for backward compatibility with existing code that didn't
|
||||
* add a \`<p>\` around edge labels.
|
||||
*
|
||||
* TODO: We should probably remove this in a future release.
|
||||
*/
|
||||
p {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: inline;
|
||||
}
|
||||
rect {
|
||||
opacity: 0.5;
|
||||
background-color: ${e.edgeLabelBackground};
|
||||
fill: ${e.edgeLabelBackground};
|
||||
}
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* For html labels only */
|
||||
.labelBkg {
|
||||
background-color: ${e.edgeLabelBackground};
|
||||
}
|
||||
|
||||
.node .cluster {
|
||||
// fill: ${re(e.mainBkg,.5)};
|
||||
fill: ${re(e.clusterBkg,.5)};
|
||||
stroke: ${re(e.clusterBorder,.2)};
|
||||
box-shadow: rgba(50, 50, 93, 0.25) 0px 13px 27px -5px, rgba(0, 0, 0, 0.3) 0px 8px 16px -8px;
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
.cluster text {
|
||||
fill: ${e.titleColor};
|
||||
}
|
||||
|
||||
.cluster span {
|
||||
color: ${e.titleColor};
|
||||
}
|
||||
/* .cluster div {
|
||||
color: ${e.titleColor};
|
||||
} */
|
||||
|
||||
div.mermaidTooltip {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
max-width: 200px;
|
||||
padding: 2px;
|
||||
font-family: ${e.fontFamily};
|
||||
font-size: 12px;
|
||||
background: ${e.tertiaryColor};
|
||||
border: 1px solid ${e.border2};
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.flowchartTitleText {
|
||||
text-anchor: middle;
|
||||
font-size: 18px;
|
||||
fill: ${e.textColor};
|
||||
}
|
||||
${Pe()}
|
||||
`,"getStyles"),Gt=jt;function ne(e,a){if(e===0||!Number.isInteger(e))throw new Error("Columns must be an integer !== 0.");if(a<0||!Number.isInteger(a))throw new Error("Position must be a non-negative integer."+a);if(e<0)return{px:a,py:0};if(e===1)return{px:0,py:a};const o=a%e,r=Math.floor(a/e);return{px:o,py:r}}u(ne,"calculateBlockPosition");var qt=u(e=>{let a=0,o=0;for(const r of e.children){const{width:l,height:d,x:s,y:h}=r.size??{width:0,height:0,x:0,y:0};if(m.debug("getMaxChildSize abc95 child:",r.id,"width:",l,"height:",d,"x:",s,"y:",h,r.type),r.type==="space")continue;const x=l/(r.widthInColumns??1);x>a&&(a=x),d>o&&(o=d)}return{width:a,height:o}},"getMaxChildSize");function G(e,a,o=0,r=0,l=8){m.debug("setBlockSizes abc95 (start)",e.id,e?.size?.x,"block width =",e?.size,"siblingWidth",o),e?.size?.width||(e.size={width:o,height:r,x:0,y:0});let d=0,s=0;if(e.children?.length>0){for(const b of e.children)G(b,a,0,0,l);const h=qt(e);d=h.width,s=h.height,m.debug("setBlockSizes abc95 maxWidth of",e.id,":s children is ",d,s);for(const b of e.children)b.size&&(m.debug(`abc95 Setting size of children of ${e.id} id=${b.id} ${d} ${s} ${JSON.stringify(b.size)}`),b.size.width=d*(b.widthInColumns??1)+l*((b.widthInColumns??1)-1),b.size.height=s,b.size.x=0,b.size.y=0,m.debug(`abc95 updating size of ${e.id} children child:${b.id} maxWidth:${d} maxHeight:${s}`));for(const b of e.children)G(b,a,d,s,l);const x=e.columns??-1;let y=0;for(const b of e.children)y+=b.widthInColumns??1;let S=e.children.length;x>0&&x<y&&(S=x);const f=Math.ceil(y/S);let L=S*(d+l)+l,w=f*(s+l)+l;if(L<o){m.debug(`Detected to small sibling: abc95 ${e.id} siblingWidth ${o} siblingHeight ${r} width ${L}`),L=o,w=r;const b=(o-S*l-l)/S,k=(r-f*l-l)/f;m.debug("Size indata abc88",e.id,"childWidth",b,"maxWidth",d),m.debug("Size indata abc88",e.id,"childHeight",k,"maxHeight",s),m.debug("Size indata abc88 xSize",S,"padding",l);for(const _ of e.children)_.size&&(_.size.width=b,_.size.height=k,_.size.x=0,_.size.y=0)}if(m.debug(`abc95 (finale calc) ${e.id} xSize ${S} ySize ${f} columns ${x}${e.children.length} width=${Math.max(L,e.size?.width||0)}`),L<(e?.size?.width||0)){L=e?.size?.width||0;const b=x>0?Math.min(e.children.length,x):e.children.length;if(b>0){const k=(L-b*l-l)/b;m.debug("abc95 (growing to fit) width",e.id,L,e.size?.width,k);for(const _ of e.children)_.size&&(_.size.width=k)}}e.size={width:L,height:w,x:0,y:0}}m.debug("setBlockSizes abc94 (done)",e.id,e?.size?.x,e?.size?.width,e?.size?.y,e?.size?.height)}u(G,"setBlockSizes");function ue(e,a,o=8){m.debug(`abc85 layout blocks (=>layoutBlocks) ${e.id} x: ${e?.size?.x} y: ${e?.size?.y} width: ${e?.size?.width}`);const r=e.columns??-1;if(m.debug("layoutBlocks columns abc95",e.id,"=>",r,e),e.children&&e.children.length>0){const l=e?.children[0]?.size?.width??0,d=e.children.length*l+(e.children.length-1)*o;m.debug("widthOfChildren 88",d,"posX");const s=new Map;{let f=0;for(const L of e.children){if(!L.size)continue;const{py:w}=ne(r,f),b=s.get(w)??0;L.size.height>b&&s.set(w,L.size.height);let k=L?.widthInColumns??1;r>0&&(k=Math.min(k,r-f%r)),f+=k}}const h=new Map;{let f=0;const L=[...s.keys()].sort((w,b)=>w-b);for(const w of L)h.set(w,f),f+=(s.get(w)??0)+o}let x=0;m.debug("abc91 block?.size?.x",e.id,e?.size?.x);let y=e?.size?.x?e?.size?.x+(-e?.size?.width/2||0):-o,S=0;for(const f of e.children){const L=e;if(!f.size)continue;const{width:w,height:b}=f.size,{px:k,py:_}=ne(r,x);if(_!=S&&(S=_,y=e?.size?.x?e?.size?.x+(-e?.size?.width/2||0):-o,m.debug("New row in layout for block",e.id," and child ",f.id,S)),m.debug(`abc89 layout blocks (child) id: ${f.id} Pos: ${x} (px, py) ${k},${_} (${L?.size?.x},${L?.size?.y}) parent: ${L.id} width: ${w}${o}`),L.size){const O=w/2;f.size.x=y+o+O,m.debug(`abc91 layout blocks (calc) px, pyid:${f.id} startingPos=X${y} new startingPosX${f.size.x} ${O} padding=${o} width=${w} halfWidth=${O} => x:${f.size.x} y:${f.size.y} ${f.widthInColumns} (width * (child?.w || 1)) / 2 ${w*(f?.widthInColumns??1)/2}`),y=f.size.x+O;const v=h.get(_)??0,g=s.get(_)??b;f.size.y=L.size.y-L.size.height/2+v+g/2+o,m.debug(`abc88 layout blocks (calc) px, pyid:${f.id}startingPosX${y}${o}${O}=>x:${f.size.x}y:${f.size.y}${f.widthInColumns}(width * (child?.w || 1)) / 2${w*(f?.widthInColumns??1)/2}`)}f.children&&ue(f,a,o);let T=f?.widthInColumns??1;r>0&&(T=Math.min(T,r-x%r)),x+=T,m.debug("abc88 columnsPos",f,x)}}m.debug(`layout blocks (<==layoutBlocks) ${e.id} x: ${e?.size?.x} y: ${e?.size?.y} width: ${e?.size?.width}`)}u(ue,"layoutBlocks");function ge(e,{minX:a,minY:o,maxX:r,maxY:l}={minX:0,minY:0,maxX:0,maxY:0}){if(e.size&&e.id!=="root"){const{x:d,y:s,width:h,height:x}=e.size;d-h/2<a&&(a=d-h/2),s-x/2<o&&(o=s-x/2),d+h/2>r&&(r=d+h/2),s+x/2>l&&(l=s+x/2)}if(e.children)for(const d of e.children)({minX:a,minY:o,maxX:r,maxY:l}=ge(d,{minX:a,minY:o,maxX:r,maxY:l}));return{minX:a,minY:o,maxX:r,maxY:l}}u(ge,"findBounds");function Ie(e){const a=e.getBlock("root");if(!a)return;const o=Le()?.block?.padding??8;G(a,e,0,0,o),ue(a,
Line truncated
|
||||
@@ -0,0 +1,38 @@
|
||||
import{d as Le}from"./chunk-F27PBJKO.js";import{s as Ie,g as Me,a as Be,b as Ve,_ as d,c as wt,l as he,d as Ye,e as Fe,f as Dt,h as Ue,i as be,j as Jt,w as je,k as $t,m as de}from"./mermaid.core.js";import"./index.js";var Ut=(function(){var t=d(function(yt,m,x,E){for(x=x||{},E=yt.length;E--;x[yt[E]]=m);return x},"o"),e=[1,24],s=[1,25],a=[1,26],u=[1,27],i=[1,28],n=[1,63],l=[1,64],r=[1,65],h=[1,66],y=[1,67],f=[1,68],b=[1,69],v=[1,29],w=[1,30],N=[1,31],O=[1,32],P=[1,33],I=[1,34],W=[1,35],X=[1,36],q=[1,37],H=[1,38],Q=[1,39],K=[1,40],G=[1,41],J=[1,42],Z=[1,43],$=[1,44],tt=[1,45],et=[1,46],nt=[1,47],st=[1,48],rt=[1,50],at=[1,51],it=[1,52],ot=[1,53],lt=[1,54],ct=[1,55],ut=[1,56],ht=[1,57],dt=[1,58],pt=[1,59],ft=[1,60],kt=[14,42],Wt=[14,34,36,37,38,39,40,41,42,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74],Ot=[12,14,34,36,37,38,39,40,41,42,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74],S=[1,82],k=[1,83],T=[1,84],C=[1,85],R=[12,14,42],ie=[12,14,33,42],Mt=[12,14,33,42,76,77,79,80],gt=[12,33],Xt=[34,36,37,38,39,40,41,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74],qt={trace:d(function(){},"trace"),yy:{},symbols_:{error:2,start:3,mermaidDoc:4,direction:5,direction_tb:6,direction_bt:7,direction_rl:8,direction_lr:9,graphConfig:10,C4_CONTEXT:11,NEWLINE:12,statements:13,EOF:14,C4_CONTAINER:15,C4_COMPONENT:16,C4_DYNAMIC:17,C4_DEPLOYMENT:18,otherStatements:19,diagramStatements:20,otherStatement:21,title:22,accDescription:23,acc_title:24,acc_title_value:25,acc_descr:26,acc_descr_value:27,acc_descr_multiline_value:28,boundaryStatement:29,boundaryStartStatement:30,boundaryStopStatement:31,boundaryStart:32,LBRACE:33,ENTERPRISE_BOUNDARY:34,attributes:35,SYSTEM_BOUNDARY:36,BOUNDARY:37,CONTAINER_BOUNDARY:38,NODE:39,NODE_L:40,NODE_R:41,RBRACE:42,diagramStatement:43,PERSON:44,PERSON_EXT:45,SYSTEM:46,SYSTEM_DB:47,SYSTEM_QUEUE:48,SYSTEM_EXT:49,SYSTEM_EXT_DB:50,SYSTEM_EXT_QUEUE:51,CONTAINER:52,CONTAINER_DB:53,CONTAINER_QUEUE:54,CONTAINER_EXT:55,CONTAINER_EXT_DB:56,CONTAINER_EXT_QUEUE:57,COMPONENT:58,COMPONENT_DB:59,COMPONENT_QUEUE:60,COMPONENT_EXT:61,COMPONENT_EXT_DB:62,COMPONENT_EXT_QUEUE:63,REL:64,BIREL:65,REL_U:66,REL_D:67,REL_L:68,REL_R:69,REL_B:70,REL_INDEX:71,UPDATE_EL_STYLE:72,UPDATE_REL_STYLE:73,UPDATE_LAYOUT_CONFIG:74,attribute:75,STR:76,STR_KEY:77,STR_VALUE:78,ATTRIBUTE:79,ATTRIBUTE_EMPTY:80,$accept:0,$end:1},terminals_:{2:"error",6:"direction_tb",7:"direction_bt",8:"direction_rl",9:"direction_lr",11:"C4_CONTEXT",12:"NEWLINE",14:"EOF",15:"C4_CONTAINER",16:"C4_COMPONENT",17:"C4_DYNAMIC",18:"C4_DEPLOYMENT",22:"title",23:"accDescription",24:"acc_title",25:"acc_title_value",26:"acc_descr",27:"acc_descr_value",28:"acc_descr_multiline_value",33:"LBRACE",34:"ENTERPRISE_BOUNDARY",36:"SYSTEM_BOUNDARY",37:"BOUNDARY",38:"CONTAINER_BOUNDARY",39:"NODE",40:"NODE_L",41:"NODE_R",42:"RBRACE",44:"PERSON",45:"PERSON_EXT",46:"SYSTEM",47:"SYSTEM_DB",48:"SYSTEM_QUEUE",49:"SYSTEM_EXT",50:"SYSTEM_EXT_DB",51:"SYSTEM_EXT_QUEUE",52:"CONTAINER",53:"CONTAINER_DB",54:"CONTAINER_QUEUE",55:"CONTAINER_EXT",56:"CONTAINER_EXT_DB",57:"CONTAINER_EXT_QUEUE",58:"COMPONENT",59:"COMPONENT_DB",60:"COMPONENT_QUEUE",61:"COMPONENT_EXT",62:"COMPONENT_EXT_DB",63:"COMPONENT_EXT_QUEUE",64:"REL",65:"BIREL",66:"REL_U",67:"REL_D",68:"REL_L",69:"REL_R",70:"REL_B",71:"REL_INDEX",72:"UPDATE_EL_STYLE",73:"UPDATE_REL_STYLE",74:"UPDATE_LAYOUT_CONFIG",76:"STR",77:"STR_KEY",78:"STR_VALUE",79:"ATTRIBUTE",80:"ATTRIBUTE_EMPTY"},productions_:[0,[3,1],[3,1],[5,1],[5,1],[5,1],[5,1],[4,1],[10,4],[10,4],[10,4],[10,4],[10,4],[13,1],[13,1],[13,2],[19,1],[19,2],[19,3],[21,1],[21,1],[21,2],[21,2],[21,1],[29,3],[30,3],[30,3],[30,4],[32,2],[32,2],[32,2],[32,2],[32,2],[32,2],[32,2],[31,1],[20,1],[20,2],[20,3],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,1],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[35,1],[35,2],[75,1],[75,2],[75,1],[75,1]],performAction:d(function(m,x,E,_,D,o,Pt){var p=o.length-1;switch(D){case 3:_.setDirection("TB");break;case 4:_.setDirection("BT");break;case 5:_.setDirection("RL");break;case 6:_.setDirection("LR");break;case 8:case 9:case 10:case 11:case 12:_.setC4Type(o[p-3]);break;case 19:_.setTitle(o[p].substring(6)),this.$=o[p].substring(6);break;case 20:_.setAccDescription(o[p].substring(15)),this.$=o[p].substring(15);break;case 21:this.$=o[p].trim(),_.setTitle(this.$);break;case 22:case 23:this.$=o[p].trim(),_.setAccDescription(this.$);break;case 28:o[p].splice(2,0,"ENTERPRISE"),_.addPersonOrSystemBoundary(...o[p]),this.$=o[p];break;case 29:o[p].splice(2,0,"SYSTEM"),_.addPersonOrSystemBoundary(...o[p]),this.$=o[p];break;case 30:_.addPersonOrSystemBoundary(...o[p]),this.$=o[p];break;case 31:o[p].splice(2,0,"CONTAINER"),_.addContainerBoundary(...o[p]),this.$=o[p];break;case 32:_.addDeploymentNode("node",...Line truncated
|
||||
`+A.showPosition()+`
|
||||
Expecting `+Yt.join(", ")+", got '"+(this.terminals_[M]||M)+"'":Gt="Parse error on line "+(xt+1)+": Unexpected "+(M==le?"end of input":"'"+(this.terminals_[M]||M)+"'"),this.parseError(Gt,{text:A.match,token:this.terminals_[M]||M,line:A.yylineno,loc:Qt,expected:Yt})}if(V[0]instanceof Array&&V.length>1)throw new Error("Parse Error: multiple actions possible at state: "+Et+", token: "+M);switch(V[0]){case 1:E.push(M),D.push(A.yytext),o.push(A.yylloc),E.push(V[1]),M=null,oe=A.yyleng,p=A.yytext,xt=A.yylineno,Qt=A.yylloc;break;case 2:if(j=this.productions_[V[1]][1],Tt.$=D[D.length-j],Tt._$={first_line:o[o.length-(j||1)].first_line,last_line:o[o.length-1].last_line,first_column:o[o.length-(j||1)].first_column,last_column:o[o.length-1].last_column},Ne&&(Tt._$.range=[o[o.length-(j||1)].range[0],o[o.length-1].range[1]]),Kt=this.performAction.apply(Tt,[p,oe,xt,vt.yy,V[1],D,o].concat(De)),typeof Kt<"u")return Kt;j&&(E=E.slice(0,-1*j*2),D=D.slice(0,-1*j),o=o.slice(0,-1*j)),E.push(this.productions_[V[1]][0]),D.push(Tt.$),o.push(Tt._$),ue=Pt[E[E.length-2]][E[E.length-1]],E.push(ue);break;case 3:return!0}}return!0},"parse")},Oe=(function(){var yt={EOF:1,parseError:d(function(x,E){if(this.yy.parser)this.yy.parser.parseError(x,E);else throw new Error(x)},"parseError"),setInput:d(function(m,x){return this.yy=x||this.yy||{},this._input=m,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:d(function(){var m=this._input[0];this.yytext+=m,this.yyleng++,this.offset++,this.match+=m,this.matched+=m;var x=m.match(/(?:\r\n?|\n).*/g);return x?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),m},"input"),unput:d(function(m){var x=m.length,E=m.split(/(?:\r\n?|\n)/g);this._input=m+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-x),this.offset-=x;var _=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),E.length-1&&(this.yylineno-=E.length-1);var D=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:E?(E.length===_.length?this.yylloc.first_column:0)+_[_.length-E.length].length-E[0].length:this.yylloc.first_column-x},this.options.ranges&&(this.yylloc.range=[D[0],D[0]+this.yyleng-x]),this.yyleng=this.yytext.length,this},"unput"),more:d(function(){return this._more=!0,this},"more"),reject:d(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true).
|
||||
`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:d(function(m){this.unput(this.match.slice(m))},"less"),pastInput:d(function(){var m=this.matched.substr(0,this.matched.length-this.match.length);return(m.length>20?"...":"")+m.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:d(function(){var m=this.match;return m.length<20&&(m+=this._input.substr(0,20-m.length)),(m.substr(0,20)+(m.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:d(function(){var m=this.pastInput(),x=new Array(m.length+1).join("-");return m+this.upcomingInput()+`
|
||||
`+x+"^"},"showPosition"),test_match:d(function(m,x){var E,_,D;if(this.options.backtrack_lexer&&(D={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(D.yylloc.range=this.yylloc.range.slice(0))),_=m[0].match(/(?:\r\n?|\n).*/g),_&&(this.yylineno+=_.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:_?_[_.length-1].length-_[_.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+m[0].length},this.yytext+=m[0],this.match+=m[0],this.matches=m,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(m[0].length),this.matched+=m[0],E=this.performAction.call(this,this.yy,this,x,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),E)return E;if(this._backtrack){for(var o in D)this[o]=D[o];return!1}return!1},"test_match"),next:d(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var m,x,E,_;this._more||(this.yytext="",this.match="");for(var D=this._currentRules(),o=0;o<D.length;o++)if(E=this._input.match(this.rules[D[o]]),E&&(!x||E[0].length>x[0].length)){if(x=E,_=o,this.options.backtrack_lexer){if(m=this.test_match(E,D[o]),m!==!1)return m;if(this._backtrack){x=!1;continue}else return!1}else if(!this.options.flex)break}return x?(m=this.test_match(x,D[_]),m!==!1?m:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text.
|
||||
`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:d(function(){var x=this.next();return x||this.lex()},"lex"),begin:d(function(x){this.conditionStack.push(x)},"begin"),popState:d(function(){var x=this.conditionStack.length-1;return x>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:d(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:d(function(x){return x=this.conditionStack.length-1-Math.abs(x||0),x>=0?this.conditionStack[x]:"INITIAL"},"topState"),pushState:d(function(x){this.begin(x)},"pushState"),stateStackSize:d(function(){return this.conditionStack.length},"stateStackSize"),options:{},performAction:d(function(x,E,_,D){switch(_){case 0:return 6;case 1:return 7;case 2:return 8;case 3:return 9;case 4:return 22;case 5:return 23;case 6:return this.begin("acc_title"),24;case 7:return this.popState(),"acc_title_value";case 8:return this.begin("acc_descr"),26;case 9:return this.popState(),"acc_descr_value";case 10:this.begin("acc_descr_multiline");break;case 11:this.popState();break;case 12:return"acc_descr_multiline_value";case 13:break;case 14:c;break;case 15:return 12;case 16:break;case 17:return 11;case 18:return 15;case 19:return 16;case 20:return 17;case 21:return 18;case 22:return this.begin("person_ext"),45;case 23:return this.begin("person"),44;case 24:return this.begin("system_ext_queue"),51;case 25:return this.begin("system_ext_db"),50;case 26:return this.begin("system_ext"),49;case 27:return this.begin("system_queue"),48;case 28:return this.begin("system_db"),47;case 29:return this.begin("system"),46;case 30:return this.begin("boundary"),37;case 31:return this.begin("enterprise_boundary"),34;case 32:return this.begin("system_boundary"),36;case 33:return this.begin("container_ext_queue"),57;case 34:return this.begin("container_ext_db"),56;case 35:return this.begin("container_ext"),55;case 36:return this.begin("container_queue"),54;case 37:return this.begin("container_db"),53;case 38:return this.begin("container"),52;case 39:return this.begin("container_boundary"),38;case 40:return this.begin("component_ext_queue"),63;case 41:return this.begin("component_ext_db"),62;case 42:return this.begin("component_ext"),61;case 43:return this.begin("component_queue"),60;case 44:return this.begin("component_db"),59;case 45:return this.begin("component"),58;case 46:return this.begin("node"),39;case 47:return this.begin("node"),39;case 48:return this.begin("node_l"),40;case 49:return this.begin("node_r"),41;case 50:return this.begin("rel"),64;case 51:return this.begin("birel"),65;case 52:return this.begin("rel_u"),66;case 53:return this.begin("rel_u"),66;case 54:return this.begin("rel_d"),67;case 55:return this.begin("rel_d"),67;case 56:return this.begin("rel_l"),68;case 57:return this.begin("rel_l"),68;case 58:return this.begin("rel_r"),69;case 59:return this.begin("rel_r"),69;case 60:return this.begin("rel_b"),70;case 61:return this.begin("rel_index"),71;case 62:return this.begin("update_el_style"),72;case 63:return this.begin("update_rel_style"),73;case 64:return this.begin("update_layout_config"),74;case 65:return"EOF_IN_STRUCT";case 66:return this.begin("attribute"),"ATTRIBUTE_EMPTY";case 67:this.begin("attribute");break;case 68:this.popState(),this.popState();break;case 69:return 80;case 70:break;case 71:return 80;case 72:this.begin("string");break;case 73:this.popState();break;case 74:return"STR";case 75:this.begin("string_kv");break;case 76:return this.begin("string_kv_key"),"STR_KEY";case 77:this.popState(),this.begin("string_kv_value");break;case 78:return"STR_VALUE";case 79:this.popState(),this.popState();break;case 80:return"STR";case 81:return"LBRACE";case 82:return"RBRACE";case 83:return"SPACE";case 84:return"EOL";case 85:return 14}},"anonymous"),rules:[/^(?:.*direction\s+TB[^\n]*)/,/^(?:.*direction\s+BT[^\n]*)/,/^(?:.*direction\s+RL[^\n]*)/,/^(?:.*direction\s+LR[^\n]*)/,/^(?:title\s[^#\n;]+)/,/^(?:accDescription\s[^#\n;]+)/,/^(?:accTitle\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*\{\s*)/,/^(?:[\}])/,/^(?:[^\}]*)/,/^(?:%%(?!\{)*[^\n]*(\r?\n?)+)/,/^(?:%%[^\n]*(\r?\n)*)/,/^(?:\s*(\r?\n)+)/,/^(?:\s+)/,/^(?:C4Context\b)/,/^(?:C4Container\b)/,/^(?:C4Component\b)/,/^(?:C4Dynamic\b)/,/^(?:C4Deployment\b)/,/^(?:Person_Ext\b)/,/^(?:Person\b)/,/^(?:SystemQueue_Ext\b)/,/^(?:SystemDb_Ext\b)/,/^(?:System_Ext\b)/,/^(?:SystemQueue\b)/,/^(?:SystemDb\b)/,/^(?:System\b)/,/^(?:Boundary\b)/,/^(?:Enterprise_Boundary\b)/,/^(?:System_Boundary\b)/,/^(?:ContainerQueue_Ext\b)/,/^(?:ContainerDb_Ext\b)/,/^(?:Container_Ext\b)/,/^(?:ContainerQueue\b)/,/^(?:ContainerDb\b)/,/^(?:Container\b)/,/^(?:Container_Boundary\b)/,/^(?:ComponentQueue_Ext\b)/,/^(?:ComponentDb_Ext\b)/,/^(?:Component_Ext\b)/,/^(?:ComponentQueue\b)/,/^(?:ComponentDb\b)/,/^(?:Component\bLine truncated
|
||||
`)},"elementFontStyles"),W0=d(t=>`.person {
|
||||
stroke: ${t.personBorder};
|
||||
fill: ${t.personBkg};
|
||||
}
|
||||
${z0()}
|
||||
|
||||
/* The element font colour is set inline per element (default white); the
|
||||
label text takes it via currentColor. */
|
||||
.c4-shape .label,
|
||||
.c4-shape .label text {
|
||||
color: inherit;
|
||||
fill: currentColor;
|
||||
}
|
||||
/* Structurizr typography: bold name, smaller stereotype/type and description lines. */
|
||||
.c4-shape .label .c4-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
.c4-shape .label .c4-type {
|
||||
font-size: 0.75em;
|
||||
}
|
||||
.c4-shape .label .c4-descr {
|
||||
font-size: 0.82em;
|
||||
}
|
||||
.c4-shape .basic,
|
||||
.c4-shape rect,
|
||||
.c4-shape path,
|
||||
.c4-shape circle,
|
||||
.c4-shape ellipse,
|
||||
.c4-shape line {
|
||||
stroke-width: 2px;
|
||||
}
|
||||
`,"getStyles"),X0=W0,K0={parser:ze,db:te,renderer:_e,styles:X0,init:d(({c4:t,wrap:e})=>{_e.setConf(t),te.setWrap(e)},"init")};export{K0 as diagram};
|
||||
@@ -0,0 +1 @@
|
||||
import{ai as o,aj as n}from"./mermaid.core.js";const t=(a,r)=>o.lang.round(n.parse(a)[r]);export{t as c};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as s}from"./mermaid.core.js";var t,e=(t=class{constructor(i){this.init=i,this.records=this.init()}reset(){this.records=this.init()}},s(t,"ImperativeState"),t);export{e as I};
|
||||
@@ -0,0 +1,15 @@
|
||||
import{_ as e}from"./mermaid.core.js";var l=e(()=>`
|
||||
/* Font Awesome icon styling - consolidated */
|
||||
.label-icon {
|
||||
display: inline-block;
|
||||
height: 1em;
|
||||
overflow: visible;
|
||||
vertical-align: -0.125em;
|
||||
}
|
||||
|
||||
.node .label-icon path {
|
||||
fill: currentColor;
|
||||
stroke: revert;
|
||||
stroke-width: revert;
|
||||
}
|
||||
`,"getIconStyles");export{l as g};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as i,j as l,U as d,G as o}from"./mermaid.core.js";var x=i((r,t)=>{const e=r.append("rect");if(e.attr("x",t.x),e.attr("y",t.y),e.attr("fill",t.fill),e.attr("stroke",t.stroke),e.attr("width",t.width),e.attr("height",t.height),t.name&&e.attr("name",t.name),t.rx&&e.attr("rx",t.rx),t.ry&&e.attr("ry",t.ry),t.attrs!==void 0)for(const s in t.attrs)e.attr(s,t.attrs[s]);return t.class&&e.attr("class",t.class),e},"drawRect"),p=i((r,t)=>{const e={x:t.startx,y:t.starty,width:t.stopx-t.startx,height:t.stopy-t.starty,fill:t.fill,stroke:t.stroke,class:"rect"};x(r,e).lower()},"drawBackgroundRect"),y=i((r,t)=>{const e=t.text.replace(d," "),s=r.append("text");s.attr("x",t.x),s.attr("y",t.y),s.attr("class","legend"),s.style("text-anchor",t.anchor),t.class&&s.attr("class",t.class);const a=s.append("tspan");return a.attr("x",t.x+t.textMargin*2),a.text(e),s},"drawText"),m=i((r,t,e,s)=>{const a=r.append("image");a.attr("x",t),a.attr("y",e);const n=o.sanitizeUrl(s);a.attr("xlink:href",n)},"drawImage"),g=i((r,t,e,s)=>{const a=r.append("use");a.attr("x",t),a.attr("y",e);const n=o.sanitizeUrl(s);a.attr("xlink:href",`#${n}`)},"drawEmbeddedImage"),h=i(()=>({x:0,y:0,width:100,height:100,fill:"#EDF2AE",stroke:"#666",anchor:"start",rx:0,ry:0}),"getNoteRect"),f=i(()=>({x:0,y:0,width:100,height:100,"text-anchor":"start",style:"#666",textMargin:0,rx:0,ry:0,tspan:!0}),"getTextObj"),w=i(()=>{let r=l(".mermaidTooltip");return r.empty()&&(r=l("body").append("div").attr("class","mermaidTooltip").style("opacity",0).style("position","absolute").style("text-align","center").style("max-width","200px").style("padding","2px").style("font-size","12px").style("background","#ffffde").style("border","1px solid #333").style("border-radius","2px").style("pointer-events","none").style("z-index","100")),r},"createTooltip");export{p as a,h as b,g as c,x as d,m as e,w as f,f as g,y as h};
|
||||
@@ -0,0 +1,231 @@
|
||||
import{g as ee}from"./chunk-XXDRQBXY.js";import{s as se}from"./chunk-POPQ4Y6H.js";import{_ as p,l as b,c as $,v as ie,x as re,a as ae,b as ne,g as oe,s as le,n as ce,o as he,T as ue,k as j,p as de,j as kt,O as fe}from"./mermaid.core.js";import{f as pe}from"./chunk-F27PBJKO.js";var Ct=(function(){var t=p(function(V,o,h,a){for(h=h||{},a=V.length;a--;h[V[a]]=o);return h},"o"),e=[1,2],s=[1,3],n=[1,4],r=[2,4],c=[1,9],u=[1,11],S=[1,16],d=[1,17],T=[1,18],E=[1,19],m=[1,33],L=[1,20],D=[1,21],f=[1,22],I=[1,23],R=[1,24],C=[1,26],F=[1,27],A=[1,28],P=[1,29],w=[1,30],z=[1,31],rt=[1,32],at=[1,35],nt=[1,36],ot=[1,37],lt=[1,38],K=[1,34],y=[1,4,5,16,17,19,21,22,24,25,26,27,28,29,33,35,37,38,41,45,48,51,52,53,54,57],ct=[1,4,5,14,15,16,17,19,21,22,24,25,26,27,28,29,33,35,37,38,39,40,41,45,48,51,52,53,54,57],Lt=[4,5,16,17,19,21,22,24,25,26,27,28,29,33,35,37,38,41,45,48,51,52,53,54,57],gt={trace:p(function(){},"trace"),yy:{},symbols_:{error:2,start:3,SPACE:4,NL:5,SD:6,document:7,line:8,statement:9,classDefStatement:10,styleStatement:11,cssClassStatement:12,idStatement:13,DESCR:14,"-->":15,HIDE_EMPTY:16,scale:17,WIDTH:18,COMPOSIT_STATE:19,STRUCT_START:20,STRUCT_STOP:21,STATE_DESCR:22,AS:23,ID:24,FORK:25,JOIN:26,CHOICE:27,CONCURRENT:28,note:29,notePosition:30,NOTE_TEXT:31,direction:32,acc_title:33,acc_title_value:34,acc_descr:35,acc_descr_value:36,acc_descr_multiline_value:37,CLICK:38,STRING:39,HREF:40,classDef:41,CLASSDEF_ID:42,CLASSDEF_STYLEOPTS:43,DEFAULT:44,style:45,STYLE_IDS:46,STYLEDEF_STYLEOPTS:47,class:48,CLASSENTITY_IDS:49,STYLECLASS:50,direction_tb:51,direction_bt:52,direction_rl:53,direction_lr:54,eol:55,";":56,EDGE_STATE:57,STYLE_SEPARATOR:58,left_of:59,right_of:60,$accept:0,$end:1},terminals_:{2:"error",4:"SPACE",5:"NL",6:"SD",14:"DESCR",15:"-->",16:"HIDE_EMPTY",17:"scale",18:"WIDTH",19:"COMPOSIT_STATE",20:"STRUCT_START",21:"STRUCT_STOP",22:"STATE_DESCR",23:"AS",24:"ID",25:"FORK",26:"JOIN",27:"CHOICE",28:"CONCURRENT",29:"note",31:"NOTE_TEXT",33:"acc_title",34:"acc_title_value",35:"acc_descr",36:"acc_descr_value",37:"acc_descr_multiline_value",38:"CLICK",39:"STRING",40:"HREF",41:"classDef",42:"CLASSDEF_ID",43:"CLASSDEF_STYLEOPTS",44:"DEFAULT",45:"style",46:"STYLE_IDS",47:"STYLEDEF_STYLEOPTS",48:"class",49:"CLASSENTITY_IDS",50:"STYLECLASS",51:"direction_tb",52:"direction_bt",53:"direction_rl",54:"direction_lr",56:";",57:"EDGE_STATE",58:"STYLE_SEPARATOR",59:"left_of",60:"right_of"},productions_:[0,[3,2],[3,2],[3,2],[7,0],[7,2],[8,2],[8,1],[8,1],[9,1],[9,1],[9,1],[9,1],[9,2],[9,3],[9,4],[9,1],[9,2],[9,1],[9,4],[9,3],[9,6],[9,1],[9,1],[9,1],[9,1],[9,4],[9,4],[9,1],[9,2],[9,2],[9,1],[9,5],[9,5],[10,3],[10,3],[11,3],[12,3],[32,1],[32,1],[32,1],[32,1],[55,1],[55,1],[13,1],[13,1],[13,3],[13,3],[30,1],[30,1]],performAction:p(function(o,h,a,g,_,i,B){var l=i.length-1;switch(_){case 3:return g.setRootDoc(i[l]),i[l];case 4:this.$=[];break;case 5:i[l]!="nl"&&(i[l-1].push(i[l]),this.$=i[l-1]);break;case 6:case 7:this.$=i[l];break;case 8:this.$="nl";break;case 12:this.$=i[l];break;case 13:const Z=i[l-1];Z.description=g.trimColon(i[l]),this.$=Z;break;case 14:this.$={stmt:"relation",state1:i[l-2],state2:i[l]};break;case 15:const Tt=g.trimColon(i[l]);this.$={stmt:"relation",state1:i[l-3],state2:i[l-1],description:Tt};break;case 19:this.$={stmt:"state",id:i[l-3],type:"default",description:"",doc:i[l-1]};break;case 20:var Y=i[l],X=i[l-2].trim();if(i[l].match(":")){var ut=i[l].split(":");Y=ut[0],X=[X,ut[1]]}this.$={stmt:"state",id:Y,type:"default",description:X};break;case 21:this.$={stmt:"state",id:i[l-3],type:"default",description:i[l-5],doc:i[l-1]};break;case 22:this.$={stmt:"state",id:i[l],type:"fork"};break;case 23:this.$={stmt:"state",id:i[l],type:"join"};break;case 24:this.$={stmt:"state",id:i[l],type:"choice"};break;case 25:this.$={stmt:"state",id:g.getDividerId(),type:"divider"};break;case 26:this.$={stmt:"state",id:i[l-1].trim(),note:{position:i[l-2].trim(),text:i[l].trim()}};break;case 29:this.$=i[l].trim(),g.setAccTitle(this.$);break;case 30:case 31:this.$=i[l].trim(),g.setAccDescription(this.$);break;case 32:this.$={stmt:"click",id:i[l-3],url:i[l-2],tooltip:i[l-1]};break;case 33:this.$={stmt:"click",id:i[l-3],url:i[l-1],tooltip:""};break;case 34:case 35:this.$={stmt:"classDef",id:i[l-1].trim(),classes:i[l].trim()};break;case 36:this.$={stmt:"style",id:i[l-1].trim(),styleClass:i[l].trim()};break;case 37:this.$={stmt:"applyClass",id:i[l-1].trim(),styleClass:i[l].trim()};break;case 38:g.setDirection("TB"),this.$={stmt:"dir",value:"TB"};break;case 39:g.setDirection("BT"),this.$={stmt:"dir",value:"BT"};break;case 40:g.setDirection("RL"),this.$={stmt:"dir",value:"RL"};break;case 41:g.setDirection("LR"),this.$={stmt:"dir",value:"LR"};break;case 44:case 45:this.$={stmt:"state",id:i[l].trim(),type:"default",description:""};break;case 46:this.$={stmt:"state",id:i[l-2].trim(),classes:[i[l].trim()],type:"default",description:""};break;case 47:this.$={stmt:"state",id:i[l-2].trim(),classes:[i[l].trim()],type:"default",description:""};break}Line truncated
|
||||
`+k.showPosition()+`
|
||||
Expecting `+ft.join(", ")+", got '"+(this.terminals_[x]||x)+"'":bt="Parse error on line "+(Y+1)+": Unexpected "+(x==Z?"end of input":"'"+(this.terminals_[x]||x)+"'"),this.parseError(bt,{text:k.match,token:this.terminals_[x]||x,line:k.yylineno,loc:_t,expected:ft})}if(N[0]instanceof Array&&N.length>1)throw new Error("Parse Error: multiple actions possible at state: "+W+", token: "+x);switch(N[0]){case 1:a.push(x),_.push(k.yytext),i.push(k.yylloc),a.push(N[1]),x=null,X=k.yyleng,l=k.yytext,Y=k.yylineno,_t=k.yylloc;break;case 2:if(G=this.productions_[N[1]][1],J.$=_[_.length-G],J._$={first_line:i[i.length-(G||1)].first_line,last_line:i[i.length-1].last_line,first_column:i[i.length-(G||1)].first_column,last_column:i[i.length-1].last_column},Zt&&(J._$.range=[i[i.length-(G||1)].range[0],i[i.length-1].range[1]]),mt=this.performAction.apply(J,[l,X,Y,U.yy,N[1],_,i].concat(Tt)),typeof mt<"u")return mt;G&&(a=a.slice(0,-1*G*2),_=_.slice(0,-1*G),i=i.slice(0,-1*G)),a.push(this.productions_[N[1]][0]),_.push(J.$),i.push(J._$),wt=B[a[a.length-2]][a[a.length-1]],a.push(wt);break;case 3:return!0}}return!0},"parse")},Qt=(function(){var V={EOF:1,parseError:p(function(h,a){if(this.yy.parser)this.yy.parser.parseError(h,a);else throw new Error(h)},"parseError"),setInput:p(function(o,h){return this.yy=h||this.yy||{},this._input=o,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:p(function(){var o=this._input[0];this.yytext+=o,this.yyleng++,this.offset++,this.match+=o,this.matched+=o;var h=o.match(/(?:\r\n?|\n).*/g);return h?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),o},"input"),unput:p(function(o){var h=o.length,a=o.split(/(?:\r\n?|\n)/g);this._input=o+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-h),this.offset-=h;var g=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),a.length-1&&(this.yylineno-=a.length-1);var _=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:a?(a.length===g.length?this.yylloc.first_column:0)+g[g.length-a.length].length-a[0].length:this.yylloc.first_column-h},this.options.ranges&&(this.yylloc.range=[_[0],_[0]+this.yyleng-h]),this.yyleng=this.yytext.length,this},"unput"),more:p(function(){return this._more=!0,this},"more"),reject:p(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true).
|
||||
`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:p(function(o){this.unput(this.match.slice(o))},"less"),pastInput:p(function(){var o=this.matched.substr(0,this.matched.length-this.match.length);return(o.length>20?"...":"")+o.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:p(function(){var o=this.match;return o.length<20&&(o+=this._input.substr(0,20-o.length)),(o.substr(0,20)+(o.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:p(function(){var o=this.pastInput(),h=new Array(o.length+1).join("-");return o+this.upcomingInput()+`
|
||||
`+h+"^"},"showPosition"),test_match:p(function(o,h){var a,g,_;if(this.options.backtrack_lexer&&(_={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(_.yylloc.range=this.yylloc.range.slice(0))),g=o[0].match(/(?:\r\n?|\n).*/g),g&&(this.yylineno+=g.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:g?g[g.length-1].length-g[g.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+o[0].length},this.yytext+=o[0],this.match+=o[0],this.matches=o,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(o[0].length),this.matched+=o[0],a=this.performAction.call(this,this.yy,this,h,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),a)return a;if(this._backtrack){for(var i in _)this[i]=_[i];return!1}return!1},"test_match"),next:p(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var o,h,a,g;this._more||(this.yytext="",this.match="");for(var _=this._currentRules(),i=0;i<_.length;i++)if(a=this._input.match(this.rules[_[i]]),a&&(!h||a[0].length>h[0].length)){if(h=a,g=i,this.options.backtrack_lexer){if(o=this.test_match(a,_[i]),o!==!1)return o;if(this._backtrack){h=!1;continue}else return!1}else if(!this.options.flex)break}return h?(o=this.test_match(h,_[g]),o!==!1?o:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text.
|
||||
`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:p(function(){var h=this.next();return h||this.lex()},"lex"),begin:p(function(h){this.conditionStack.push(h)},"begin"),popState:p(function(){var h=this.conditionStack.length-1;return h>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:p(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:p(function(h){return h=this.conditionStack.length-1-Math.abs(h||0),h>=0?this.conditionStack[h]:"INITIAL"},"topState"),pushState:p(function(h){this.begin(h)},"pushState"),stateStackSize:p(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:p(function(h,a,g,_){function i(){const B=a.yytext.indexOf("%%");if(B===0)return!1;if(B>0){const l=a.yytext.slice(0,B),Y=a.yytext.slice(B);Y&&h.lexer.unput(Y),a.yytext=l}return!0}switch(p(i,"processId"),g){case 0:return 38;case 1:return 40;case 2:return 39;case 3:return 44;case 4:return 51;case 5:return 52;case 6:return 53;case 7:return 54;case 8:return 5;case 9:break;case 10:break;case 11:break;case 12:break;case 13:return this.pushState("SCALE"),17;case 14:return 18;case 15:this.popState();break;case 16:return this.begin("acc_title"),33;case 17:return this.popState(),"acc_title_value";case 18:return this.begin("acc_descr"),35;case 19:return this.popState(),"acc_descr_value";case 20:this.begin("acc_descr_multiline");break;case 21:this.popState();break;case 22:return"acc_descr_multiline_value";case 23:return this.pushState("CLASSDEF"),41;case 24:return this.popState(),this.pushState("CLASSDEFID"),"DEFAULT_CLASSDEF_ID";case 25:return this.popState(),this.pushState("CLASSDEFID"),42;case 26:return this.popState(),43;case 27:return this.pushState("CLASS"),48;case 28:return this.popState(),this.pushState("CLASS_STYLE"),49;case 29:return this.popState(),50;case 30:return this.pushState("STYLE"),45;case 31:return this.popState(),this.pushState("STYLEDEF_STYLES"),46;case 32:return this.popState(),47;case 33:return this.pushState("SCALE"),17;case 34:return 18;case 35:this.popState();break;case 36:this.pushState("STATE");break;case 37:return this.popState(),a.yytext=a.yytext.slice(0,-8).trim(),25;case 38:return this.popState(),a.yytext=a.yytext.slice(0,-8).trim(),26;case 39:return this.popState(),a.yytext=a.yytext.slice(0,-10).trim(),27;case 40:return this.popState(),a.yytext=a.yytext.slice(0,-8).trim(),25;case 41:return this.popState(),a.yytext=a.yytext.slice(0,-8).trim(),26;case 42:return this.popState(),a.yytext=a.yytext.slice(0,-10).trim(),27;case 43:return 51;case 44:return 52;case 45:return 53;case 46:return 54;case 47:this.pushState("STATE_STRING");break;case 48:return this.pushState("STATE_ID"),"AS";case 49:return i()?(this.popState(),"ID"):void 0;case 50:this.popState();break;case 51:return"STATE_DESCR";case 52:throw new Error('Error: State name must be a single word. Found: "'+a.yytext.trim()+'"');case 53:return 19;case 54:this.popState();break;case 55:return this.popState(),this.pushState("struct"),20;case 56:return this.popState(),21;case 57:break;case 58:return this.begin("NOTE"),29;case 59:return this.popState(),this.pushState("NOTE_ID"),59;case 60:return this.popState(),this.pushState("NOTE_ID"),60;case 61:this.popState(),this.pushState("FLOATING_NOTE");break;case 62:return this.popState(),this.pushState("FLOATING_NOTE_ID"),"AS";case 63:break;case 64:return"NOTE_TEXT";case 65:return i()?(this.popState(),"ID"):void 0;case 66:return i()?(this.popState(),this.pushState("NOTE_TEXT"),24):void 0;case 67:return this.popState(),a.yytext=a.yytext.substr(2).trim(),31;case 68:return this.popState(),a.yytext=a.yytext.slice(0,-8).trim(),31;case 69:return 6;case 70:return 6;case 71:return 16;case 72:return 57;case 73:return i()?24:void 0;case 74:return a.yytext=a.yytext.trim(),14;case 75:return 15;case 76:return 28;case 77:return 58;case 78:return 5;case 79:return"INVALID"}},"anonymous"),rules:[/^(?:click\b)/i,/^(?:href\b)/i,/^(?:"[^"]*")/i,/^(?:default\b)/i,/^(?:.*direction\s+TB[^\n]*)/i,/^(?:.*direction\s+BT[^\n]*)/i,/^(?:.*direction\s+RL[^\n]*)/i,/^(?:.*direction\s+LR[^\n]*)/i,/^(?:[\n]+)/i,/^(?:[\s]+)/i,/^(?:((?!\n)\s)+)/i,/^(?:#[^\n]*)/i,/^(?:%%(?!\{)[^\n]*)/i,/^(?:scale\s+)/i,/^(?:\d+)/i,/^(?:\s+width\b)/i,/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:[\}])/i,/^(?:[^\}]*)/i,/^(?:classDef\s+)/i,/^(?:DEFAULT\s+)/i,/^(?:\w+\s+)/i,/^(?:[^\n]*)/i,/^(?:class\s+)/i,/^(?:(\w+)+((,\s*\w+)*))/i,/^(?:[^\n]*)/i,/^(?:style\s+)/i,/^(?:[\w,]+\s+)/i,/^(?:[^\n]*)/i,/^(?:scale\s+)/i,/^(?:\d+)/i,/^(?:\s+width\b)/i,/^(?:state\s+)/i,/^(?:.*<<fork>>)/i,/^(?:.*<<join>>)/i,/^(?:.*<<choice>>)/i,/^(?:.*\[\[fork\]\])/i,/^(?:.*\[\[join\]\])/i,/^(?:.*\[\[choice\]\])/i,/^(?:.*direction\s+TB[^\n]*)/i,/^(?:.*directio
Line truncated
|
||||
defs [id$="-barbEnd"] {
|
||||
fill: ${t.transitionColor};
|
||||
stroke: ${t.transitionColor};
|
||||
}
|
||||
g.stateGroup text {
|
||||
fill: ${t.nodeBorder};
|
||||
stroke: none;
|
||||
font-size: 10px;
|
||||
}
|
||||
g.stateGroup text {
|
||||
fill: ${t.textColor};
|
||||
stroke: none;
|
||||
font-size: 10px;
|
||||
|
||||
}
|
||||
g.stateGroup .state-title {
|
||||
font-weight: bolder;
|
||||
fill: ${t.stateLabelColor};
|
||||
}
|
||||
|
||||
g.stateGroup rect {
|
||||
fill: ${t.mainBkg};
|
||||
stroke: ${t.nodeBorder};
|
||||
}
|
||||
|
||||
g.stateGroup line {
|
||||
stroke: ${t.lineColor};
|
||||
stroke-width: ${t.strokeWidth||1};
|
||||
}
|
||||
|
||||
.transition {
|
||||
stroke: ${t.transitionColor};
|
||||
stroke-width: ${t.strokeWidth||1};
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.stateGroup .composit {
|
||||
fill: ${t.background};
|
||||
border-bottom: 1px
|
||||
}
|
||||
|
||||
.stateGroup .alt-composit {
|
||||
fill: #e0e0e0;
|
||||
border-bottom: 1px
|
||||
}
|
||||
|
||||
.state-note {
|
||||
stroke: ${t.noteBorderColor};
|
||||
fill: ${t.noteBkgColor};
|
||||
|
||||
text {
|
||||
fill: ${t.noteTextColor};
|
||||
stroke: none;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.stateLabel .box {
|
||||
stroke: none;
|
||||
stroke-width: 0;
|
||||
fill: ${t.mainBkg};
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.edgeLabel .label rect {
|
||||
fill: ${t.labelBackgroundColor};
|
||||
opacity: 0.5;
|
||||
}
|
||||
.edgeLabel {
|
||||
background-color: ${t.edgeLabelBackground};
|
||||
p {
|
||||
background-color: ${t.edgeLabelBackground};
|
||||
}
|
||||
rect {
|
||||
opacity: 0.5;
|
||||
background-color: ${t.edgeLabelBackground};
|
||||
fill: ${t.edgeLabelBackground};
|
||||
}
|
||||
text-align: center;
|
||||
}
|
||||
.edgeLabel .label text {
|
||||
fill: ${t.transitionLabelColor||t.tertiaryTextColor};
|
||||
}
|
||||
.label div .edgeLabel {
|
||||
color: ${t.transitionLabelColor||t.tertiaryTextColor};
|
||||
}
|
||||
|
||||
.stateLabel text {
|
||||
fill: ${t.stateLabelColor};
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.node circle.state-start {
|
||||
fill: ${t.specialStateColor};
|
||||
stroke: ${t.specialStateColor};
|
||||
}
|
||||
|
||||
.node .fork-join {
|
||||
fill: ${t.specialStateColor};
|
||||
stroke: ${t.specialStateColor};
|
||||
}
|
||||
|
||||
.node circle.state-end {
|
||||
fill: ${t.innerEndBackground};
|
||||
stroke: ${t.background};
|
||||
stroke-width: 1.5
|
||||
}
|
||||
.end-state-inner {
|
||||
fill: ${t.compositeBackground||t.background};
|
||||
// stroke: ${t.background};
|
||||
stroke-width: 1.5
|
||||
}
|
||||
|
||||
.node rect {
|
||||
fill: ${t.stateBkg||t.mainBkg};
|
||||
stroke: ${t.stateBorder||t.nodeBorder};
|
||||
stroke-width: ${t.strokeWidth||1}px;
|
||||
}
|
||||
.node polygon {
|
||||
fill: ${t.mainBkg};
|
||||
stroke: ${t.stateBorder||t.nodeBorder};;
|
||||
stroke-width: ${t.strokeWidth||1}px;
|
||||
}
|
||||
[id$="-barbEnd"] {
|
||||
fill: ${t.lineColor};
|
||||
}
|
||||
|
||||
.statediagram-cluster rect {
|
||||
fill: ${t.compositeTitleBackground};
|
||||
stroke: ${t.stateBorder||t.nodeBorder};
|
||||
stroke-width: ${t.strokeWidth||1}px;
|
||||
}
|
||||
|
||||
.cluster-label, .nodeLabel {
|
||||
color: ${t.stateLabelColor};
|
||||
// line-height: 1;
|
||||
}
|
||||
|
||||
.statediagram-cluster rect.outer {
|
||||
rx: 5px;
|
||||
ry: 5px;
|
||||
}
|
||||
.statediagram-state .divider {
|
||||
stroke: ${t.stateBorder||t.nodeBorder};
|
||||
}
|
||||
|
||||
.statediagram-state .title-state {
|
||||
rx: 5px;
|
||||
ry: 5px;
|
||||
}
|
||||
.statediagram-cluster.statediagram-cluster .inner {
|
||||
fill: ${t.compositeBackground||t.background};
|
||||
}
|
||||
.statediagram-cluster.statediagram-cluster-alt .inner {
|
||||
fill: ${t.altBackground?t.altBackground:"#efefef"};
|
||||
}
|
||||
|
||||
.statediagram-cluster .inner {
|
||||
rx:0;
|
||||
ry:0;
|
||||
}
|
||||
|
||||
.statediagram-state rect.basic {
|
||||
rx: 5px;
|
||||
ry: 5px;
|
||||
}
|
||||
.statediagram-state rect.divider {
|
||||
stroke-dasharray: 10,10;
|
||||
fill: ${t.altBackground?t.altBackground:"#efefef"};
|
||||
}
|
||||
|
||||
.note-edge {
|
||||
stroke-dasharray: 5;
|
||||
}
|
||||
|
||||
.statediagram-note rect {
|
||||
fill: ${t.noteBkgColor};
|
||||
stroke: ${t.noteBorderColor};
|
||||
stroke-width: 1px;
|
||||
rx: 0;
|
||||
ry: 0;
|
||||
}
|
||||
.statediagram-note rect {
|
||||
fill: ${t.noteBkgColor};
|
||||
stroke: ${t.noteBorderColor};
|
||||
stroke-width: 1px;
|
||||
rx: 0;
|
||||
ry: 0;
|
||||
}
|
||||
|
||||
.statediagram-note text {
|
||||
fill: ${t.noteTextColor};
|
||||
}
|
||||
|
||||
.statediagram-note .nodeLabel {
|
||||
color: ${t.noteTextColor};
|
||||
}
|
||||
.statediagram .edgeLabel {
|
||||
color: red; // ${t.noteTextColor};
|
||||
}
|
||||
|
||||
[id$="-dependencyStart"], [id$="-dependencyEnd"] {
|
||||
fill: ${t.lineColor};
|
||||
stroke: ${t.lineColor};
|
||||
stroke-width: ${t.strokeWidth||1};
|
||||
}
|
||||
|
||||
.statediagramTitleText {
|
||||
text-anchor: middle;
|
||||
font-size: 18px;
|
||||
fill: ${t.textColor};
|
||||
}
|
||||
|
||||
[data-look="neo"].statediagram-cluster rect {
|
||||
fill: ${t.mainBkg};
|
||||
stroke: ${t.useGradient?"url("+t.svgId+"-gradient)":t.stateBorder||t.nodeBorder};
|
||||
stroke-width: ${t.strokeWidth??1};
|
||||
}
|
||||
[data-look="neo"].statediagram-cluster rect.outer {
|
||||
rx: ${t.radius}px;
|
||||
ry: ${t.radius}px;
|
||||
filter: ${t.dropShadow?t.dropShadow.replace("url(#drop-shadow)",`url(${t.svgId}-drop-shadow)`):"none"}
|
||||
}
|
||||
`,"getStyles"),ze=Ye;export{He as S,We as a,je as b,ze as s};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as i}from"./mermaid.core.js";function t(c,e){c.accDescr&&e.setAccDescription?.(c.accDescr),c.accTitle&&e.setAccTitle?.(c.accTitle),c.title&&e.setDiagramTitle?.(c.title)}i(t,"populateCommonDb");export{t as p};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as a,d as w,l as x}from"./mermaid.core.js";var B=a((e,t,i,r)=>{e.attr("class",i);const{width:o,height:h,x:n,y:c}=u(e,t);w(e,h,o,r);const s=d(n,c,o,h,t);e.attr("viewBox",s),x.debug(`viewBox configured: ${s} with padding: ${t}`)},"setupViewPortForSVG"),u=a((e,t)=>{const i=e.node()?.getBBox()||{width:0,height:0,x:0,y:0};return{width:i.width+t*2,height:i.height+t*2,x:i.x,y:i.y}},"calculateDimensionsWithPadding"),d=a((e,t,i,r,o)=>`${e-o} ${t-o} ${i} ${r}`,"createViewBox");export{B as s};
|
||||
@@ -0,0 +1,88 @@
|
||||
import{_ as p,l as C,D as H,y as B,p as q,E as G,d as U,i as j,c as K}from"./mermaid.core.js";var A="",M="",O="",D=[],b=new Map,v=p(e=>j(e,K()),"sanitizeText"),y=p(e=>{switch(e.type){case"terminal":return{...e,value:v(e.value)};case"nonterminal":return{...e,name:v(e.name)};case"sequence":return{...e,elements:e.elements.map(y)};case"choice":return{...e,alternatives:e.alternatives.map(y)};case"optional":return{...e,element:y(e.element)};case"repetition":return{...e,element:y(e.element),separator:e.separator?y(e.separator):void 0};case"special":return{...e,text:v(e.text)}}},"sanitizeAstNode"),J=p(()=>{A="",M="",O="",D.length=0,b.clear(),q(),C.debug("[Railroad] Database cleared")},"clear"),Y=p(e=>{A=v(e),C.debug("[Railroad] Title set:",e)},"setTitle"),P=p(()=>A,"getTitle"),Q=p(e=>{const i={...e,name:v(e.name),definition:y(e.definition),comment:e.comment?v(e.comment):void 0};C.debug("[Railroad] Adding rule:",i.name),b.has(i.name)&&C.warn(`[Railroad] Rule '${i.name}' is already defined. Overwriting.`),D.push(i),b.set(i.name,i)},"addRule"),Z=p(()=>D,"getRules"),V=p(e=>b.get(e),"getRule"),ee=p(e=>{M=v(e).replace(/^\s+/g,""),C.debug("[Railroad] Accessibility title set:",e)},"setAccTitle"),te=p(()=>M,"getAccTitle"),re=p(e=>{O=v(e).replace(/\n\s+/g,`
|
||||
`),C.debug("[Railroad] Accessibility description set:",e)},"setAccDescription"),ie=p(()=>O,"getAccDescription"),ne=Y,ae=P,oe={clear:J,setTitle:Y,getTitle:P,addRule:Q,getRules:Z,getRule:V,setAccTitle:ee,getAccTitle:te,setAccDescription:re,getAccDescription:ie,setDiagramTitle:ne,getDiagramTitle:ae},f={compactMode:!1,padding:10,verticalSeparation:8,horizontalSeparation:10,arcRadius:10,fontSize:14,fontFamily:"monospace",terminalFill:"#FFFFC0",terminalStroke:"#000000",terminalTextColor:"#000000",nonTerminalFill:"#FFFFFF",nonTerminalStroke:"#000000",nonTerminalTextColor:"#000000",lineColor:"#000000",strokeWidth:2,markerFill:"#000000",commentFill:"#E8E8E8",commentStroke:"#888888",commentTextColor:"#666666",specialFill:"#F0E0FF",specialStroke:"#8800CC",ruleNameColor:"#000066",showMarkers:!0,markerRadius:5},le=/^#(?:[\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$|^(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch)\([\d\s%+,./-]+\)$|^[a-z]+$/i,se=/^[\w "',.-]+$/,de=new Set(["compactMode","padding","verticalSeparation","horizontalSeparation","arcRadius","fontSize","fontFamily","terminalFill","terminalStroke","terminalTextColor","nonTerminalFill","nonTerminalStroke","nonTerminalTextColor","lineColor","strokeWidth","markerFill","commentFill","commentStroke","commentTextColor","specialFill","specialStroke","ruleNameColor","showMarkers","markerRadius"]),L=p(e=>e?Object.keys(e).every(i=>i==="railroad"||de.has(i)):!1,"isRailroadStyleOptions"),ce=p(e=>e?"railroad"in e&&e.railroad?e.railroad:L(e)?e:{}:{},"extractRailroadOverrides"),me=p(e=>{if(!e||L(e))return{};const{railroad:i,svgId:a,theme:n,look:t,...r}=e;return r},"extractThemeOverrides"),m=p((e,i)=>{if(typeof e!="string")return i;const a=e.trim();return le.test(a)?a:i},"sanitizeColorValue"),I=p((e,i)=>{if(typeof e!="string")return i;const a=e.trim();return se.test(a)?a:i},"sanitizeFontFamilyValue"),F=p((e,i)=>{const a=typeof e=="number"?e:typeof e=="string"?Number.parseFloat(e):Number.NaN;return Number.isFinite(a)&&a>=0?a:i},"sanitizeNumberValue"),he=p(e=>{const i=typeof e=="number"?e:typeof e=="string"?Number.parseFloat(e):Number.NaN;return Number.isFinite(i)&&i>0?i:void 0},"parseThemeFontSize"),pe=p(e=>{const i=I(e.fontFamily,f.fontFamily),a=he(e.fontSize)??f.fontSize;return{...f,fontFamily:i,fontSize:a,terminalFill:m(e.secondBkg??e.secondaryColor,f.terminalFill),terminalStroke:m(e.secondaryBorderColor??e.lineColor,f.terminalStroke),terminalTextColor:m(e.secondaryTextColor??e.textColor,f.terminalTextColor),nonTerminalFill:m(e.mainBkg??e.background,f.nonTerminalFill),nonTerminalStroke:m(e.primaryBorderColor??e.lineColor,f.nonTerminalStroke),nonTerminalTextColor:m(e.primaryTextColor??e.textColor,f.nonTerminalTextColor),lineColor:m(e.lineColor,f.lineColor),markerFill:m(e.lineColor,f.markerFill),commentFill:m(e.labelBackground??e.tertiaryColor,f.commentFill),commentStroke:m(e.tertiaryBorderColor??e.lineColor,f.commentStroke),commentTextColor:m(e.tertiaryTextColor??e.textColor,f.commentTextColor),specialFill:m(e.tertiaryColor??e.secondaryColor,f.specialFill),specialStroke:m(e.tertiaryBorderColor??e.secondaryBorderColor,f.specialStroke),ruleNameColor:m(e.titleColor??e.textColor,f.ruleNameColor)}},"buildThemeDefaults"),E=p(e=>{const i=B(),a={...G(),...i.themeVariables??{},...me(e)},n=pe(a),t={...i.railroad??{},...ce(e)};return{compactMode:t.compactMode??n.compactMode,padding:F(t.padding,n.padding),verticalSeparation:F(t.verticalSeparation,n.verticalSeparation),horizontalSeparation:F(t.horizontalSeparation,n.horizontalSeparation),arcRadius:F(t.arcRadius,n.arcRadius),fontSize:F(t.fontSize,n.fontSize),fontFamily:I(t.fontFamily,n.fontFamily),terminalFill:m(t.terminalFill,n.terminalFill),terminalStroke:m(t.terminalStroke,n.terminalStroke),terminalTextColor:m(t.terminalTextColor,n.terminalTextColor),nonTerminalFill:m(t.nonTerminalFill,n.nonTerminalFill),nonTerminalStroke:m(t.nonTerminalStroke,n.nonTerminalStroke),nonTerminalTextColor:m(t.nonTerminalTextColor,n.nonTerminalTextColor),lineColor:m(t.lineColor,n.lineColor),strokeWidth:F(t.strokeWidth,n.strokeWidth),markerFill:m(t.markerFill,n.markerFill),commentFill:m(t.commentFill,n.commentFill),commentStroke:m(t.commentStroke,n.commentStroke),commentTextColor:m(t.commentTextColor,n.commentTextColor),specialFill:m(t.specialFill,n.specialFill),specialStroke:m(t.specialStroke,n.specialStroke),ruleNameColor:m(t.ruleNameColor,n.ruleNameColor),showMarkers:t.showMarkers??n.showMarkers,markerRadius:F(t.markerRadius,n.markerRadius)}},"buildRailroadStyleOptions"),Te=p(e=>{const{fontFamily:i,fontSize:a,terminalFill:n,terminalStroke:t,terminalTextColor:r,nonTerminalFill:o,nonTerminalStroke:g,nonTerminalTextColor:l,lineColor:s,strokeWidth:h,markerFill:u,commentFill:c,commentStroke:w,commentTextColor:d,specialFill:T,specialStroke:z,ruleNameColor:S}=E(e);return`
|
||||
.railroad-diagram {
|
||||
font-family: ${i};
|
||||
font-size: ${a}px;
|
||||
}
|
||||
|
||||
.railroad-terminal rect {
|
||||
fill: ${n};
|
||||
stroke: ${t};
|
||||
stroke-width: ${h}px;
|
||||
}
|
||||
|
||||
.railroad-terminal text {
|
||||
fill: ${r};
|
||||
font-family: ${i};
|
||||
font-size: ${a}px;
|
||||
text-anchor: middle;
|
||||
dominant-baseline: middle;
|
||||
}
|
||||
|
||||
.railroad-nonterminal rect {
|
||||
fill: ${o};
|
||||
stroke: ${g};
|
||||
stroke-width: ${h}px;
|
||||
}
|
||||
|
||||
.railroad-nonterminal text {
|
||||
fill: ${l};
|
||||
font-family: ${i};
|
||||
font-size: ${a}px;
|
||||
text-anchor: middle;
|
||||
dominant-baseline: middle;
|
||||
}
|
||||
|
||||
.railroad-line {
|
||||
stroke: ${s};
|
||||
stroke-width: ${h}px;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.railroad-start circle,
|
||||
.railroad-end circle {
|
||||
fill: ${u};
|
||||
}
|
||||
|
||||
.railroad-comment ellipse {
|
||||
fill: ${c};
|
||||
stroke: ${w};
|
||||
stroke-width: ${h}px;
|
||||
}
|
||||
|
||||
.railroad-comment text {
|
||||
fill: ${d};
|
||||
font-style: italic;
|
||||
font-family: ${i};
|
||||
font-size: ${a}px;
|
||||
text-anchor: middle;
|
||||
dominant-baseline: middle;
|
||||
}
|
||||
|
||||
.railroad-special rect {
|
||||
fill: ${T};
|
||||
stroke: ${z};
|
||||
stroke-width: ${h}px;
|
||||
stroke-dasharray: 5,3;
|
||||
}
|
||||
|
||||
.railroad-special text {
|
||||
fill: ${l};
|
||||
font-family: ${i};
|
||||
font-size: ${a}px;
|
||||
text-anchor: middle;
|
||||
dominant-baseline: middle;
|
||||
}
|
||||
|
||||
.railroad-rule-name {
|
||||
font-weight: bold;
|
||||
fill: ${S};
|
||||
font-family: ${i};
|
||||
font-size: ${a}px;
|
||||
}
|
||||
|
||||
.railroad-group {
|
||||
/* Grouping container, no specific styles */
|
||||
}
|
||||
`},"getStyles"),R,x=(R=class{constructor(){this.d=""}moveTo(i,a){return this.d+=`M ${i} ${a} `,this}lineTo(i,a){return this.d+=`L ${i} ${a} `,this}horizontalTo(i){return this.d+=`H ${i} `,this}verticalTo(i){return this.d+=`V ${i} `,this}arcTo(i,a,n,t,r,o,g){return this.d+=`A ${i} ${a} ${n} ${t?1:0} ${r?1:0} ${o} ${g} `,this}build(){return this.d.trim()}},p(R,"PathBuilder"),R),$,ue=($=class{constructor(i,a=E()){this.textCache=new Map,this.svg=i,this.config=a}measureText(i){if(this.textCache.has(i))return this.textCache.get(i);const a=this.svg.append("text").attr("font-family",this.config.fontFamily).attr("font-size",this.config.fontSize).text(i),n=a.node().getBBox(),t={width:n.width,height:n.height};return a.remove(),this.textCache.set(i,t),t}renderTerminal(i,a){const n=this.measureText(a),t=n.width+this.config.padding*2,r=n.height+this.config.padding*2,o=i.append("g").attr("class","railroad-terminal");return o.append("rect").attr("x",0).attr("y",0).attr("width",t).attr("height",r).attr("rx",10).attr("ry",10),o.append("text").attr("x",t/2).attr("y",r/2).text(a),{element:o.node(),dimensions:{width:t,height:r,up:r/2,down:r/2}}}renderNonTerminal(i,a){const n=this.measureText(a),t=n.width+this.config.padding*2,r=n.height+this.config.padding*2,o=i.append("g").attr("class","railroad-nonterminal");return o.append("rect").attr("x",0).attr("y",0).attr("width",t).attr("height",r),o.append("text").attr("x",t/2).attr("y",r/2).text(a),{element:o.node(),dimensions:{width:t,height:r,up:r/2,down:r/2}}}renderSequence(i,a){const n=a.map(s=>this.renderExpression(i,s));let t=0,r=0,o=0;for(const s of n)t+=s.dimensions.width,r=Math.max(r,s.dimensions.up),o=Math.max(o,s.dimensions.down);t+=(n.length-1)*this.config.horizontalSeparation;const g=i.append("g").attr("class","railroad-sequence");let l=0;for(let s=0;s<n.length;s++){const h=n[s],u=r-h.dimensions.up;if(g.node().appendChild(h.element).setAttribute("transform",`translate(${l}, ${u})`),s<n.length-1){const w=l+h.dimensions.width,d=w+this.config.horizontalSeparation,T=r;g.append("path").attr("class","railroad-line").attr("d",new x().moveTo(w,T).lineTo(d,T).build())}l+=h.dimensions.width+this.config.horizontalSeparation}return{element:g.node(),dimensions:{width:t,height:r+o,up:r,down:o}}}renderChoice(i,a){const n=a.map(c=>this.renderExpression(i,c));let t=0,r=0;for(const c of n)t=Math.max(t,c.dimensions.width),r+=c.dimensions.height;r+=(n.length-1)*this.config.verticalSeparation;const o=this.config.arcRadius,g=o*4,l=t+g,s=i.append("g").attr("class","railroad-choice");let h=0;const u=r/2;for(const c of n){const w=h,d=w+c.dimensions.up,T=o*2+(t-c.dimensions.width)/2;s.node().appendChild(c.element).setAttribute("transform",`translate(${T}, ${w})`);const S=new x,k=d>u;d===u?S.moveTo(0,u).lineTo(T,d):S.moveTo(0,u).arcTo(o,o,0,!1,k,o,u+(k?o:-o)).lineTo(o,d-(k?o:-o)).arcTo(o,o,0,!1,!k,o*2,d).lineTo(T,d),s.append("path").attr("class","railroad-line").attr("d",S.build());const N=new x,_=T+c.dimensions.width,X=l-o*2;d===u?N.moveTo(_,d).lineTo(l,u):N.moveTo(_,d).lineTo(X,d).arcTo(o,o,0,!1,!k,l-o,d+(k?-o:o)).lineTo(l-o,u+(k?o:-o)).arcTo(o,o,0,!1,k,l,u),s.append("path").attr("class","railroad-line").attr("d",N.build()),h+=c.dimensions.height+this.config.verticalSeparation}return{element:s.node(),dimensions:{width:l,height:r,up:u,down:r-u}}}renderOptional(i,a){const n=this.renderExpression(i,a),t=this.config.arcRadius,r=t*2,o=n.dimensions.width+t*4,g=n.dimensions.height+r,l=i.append("g").attr("class","railroad-optional"),s=t*2,h=r;l.node().appendChild(n.element).setAttribute("transform",`translate(${s}, ${h})`);const c=h+n.dimensions.up,w=new x().moveTo(0,c).lineTo(t*2,c);l.append("path").attr("class","railroad-line").attr("d",w.build());const d=new x().moveTo(s+n.dimensions.width,c).lineTo(o,c);l.append("path").attr("class","railroad-line").attr("d",d.build());const T=new x().moveTo(0,c).arcTo(t,t,0,!1,!1,t,c-t).lineTo(t,t).arcTo(t,t,0,!1,!0,t*2,0).lineTo(o-t*2,0).arcTo(t,t,0,!1,!0,o-t,t).lineTo(o-t,c-t).arcTo(t,t,0,!1,!1,o,c);return l.append("path").attr("class","railroad-line").attr("d",T.build()),{element:l.node(),dimensions:{width:o,height:g,up:c,down:g-c}}}renderRepetition(i,a,n){const t=this.renderExpression(i,a),r=this.config.arcRadius,o=r*2,g=t.dimensions.width+r*4,l=n===0,s=t.dimensions.height+o+(l?o:0),h=i.append("g").attr("class","railroad-repetition"),u=r*2,c=l?o:0;h.node().appendChild(t.element).setAttribute("transform",`translate(${u}, ${c})`);const d=c+t.dimensions.up;h.append("path").attr("class","railroad-line").attr("d",new x().moveTo(0,d).lineTo(r*2,d).build()),h.append("path").attr("class","railroad-line").attr("d",new x().moveTo(u+t.dimensions.width,d).lineTo(g,d).build());const T=c+t.dimensions.height+r,z=new x().moveTo(u+t.dimensions.width,d).arcTo(r,r,0,!1,!0,u+t.dimensions.width+r,d+r).lineTo(u+t.dimensions.width+r,T).arcTo(r,r,0,!1,!0,u+t.dimensions.width,T+r).lineTo(r*2,T+r).arcTo(r,r,0,!1,!0,r,T).lineTo(r,d+r).arcTo(r,r,0,!1,!0,r*2,d);if(h.append("path").attr("cl
Line truncated
|
||||
`+e);try{const n=H(i);n.attr("class","railroad-diagram");const r=B().railroad?.useMaxWidth??!0,o=oe.getRules();if(C.debug(`[Railroad] Rendering ${o.length} rules`),o.length===0){C.warn("[Railroad] No rules to render"),W(n,{height:100,width:200},r);return}const l=new ue(n,E()).renderDiagram(o);W(n,l,r),C.debug("[Railroad] Render complete")}catch(n){throw C.error("[Railroad] Render error:",n),n}},"draw"),xe={draw:ge};export{oe as d,Te as g,xe as r};
|
||||
@@ -0,0 +1,206 @@
|
||||
import{g as st}from"./chunk-5VM5RSS4.js";import{g as it}from"./chunk-XXDRQBXY.js";import{s as at}from"./chunk-POPQ4Y6H.js";import{_ as f,l as we,c as F,u as nt,v as rt,x as Ve,j as Ae,O as ut,b as lt,a as ct,s as ot,g as ht,n as dt,o as pt,k as I,p as At,q as ft,i as gt,P as U}from"./mermaid.core.js";import{f as mt}from"./chunk-F27PBJKO.js";var Pe=(function(){var s=f(function(O,o,h,p){for(h=h||{},p=O.length;p--;h[O[p]]=o);return h},"o"),i=[1,18],a=[1,19],n=[1,20],r=[1,41],c=[1,26],u=[1,42],d=[1,24],m=[1,25],g=[1,32],N=[1,33],ge=[1,34],b=[1,45],me=[1,35],Ce=[1,36],be=[1,37],ke=[1,38],Ee=[1,27],Te=[1,28],ye=[1,29],De=[1,30],Fe=[1,31],k=[1,44],E=[1,46],T=[1,43],y=[1,47],Be=[1,9],A=[1,8,9],ee=[1,58],te=[1,59],se=[1,60],ie=[1,61],ae=[1,62],_e=[1,63],Se=[1,64],_=[1,8,9,41],Me=[1,77],R=[1,8,9,12,13,22,39,41,44,46,68,69,70,71,72,73,74,79,81],ne=[1,8,9,12,13,18,20,22,39,41,44,46,47,60,68,69,70,71,72,73,74,79,81,86,100,102,103],re=[13,60,86,100,102,103],Y=[13,60,73,74,86,100,102,103],Re=[13,60,68,69,70,71,72,86,100,102,103],ue=[1,103],K=[1,121],W=[1,117],Q=[1,113],j=[1,119],X=[1,114],q=[1,115],H=[1,116],J=[1,118],Z=[1,120],Ge=[22,50,60,61,82,86,87,88,89,90],Ue=[1,128],le=[12,39],Ne=[1,8,9,39,41,44,46],ce=[1,8,9,22],ze=[1,153],Ye=[1,8,9,61],x=[1,8,9,22,50,60,61,82,86,87,88,89,90],Le={trace:f(function(){},"trace"),yy:{},symbols_:{error:2,start:3,mermaidDoc:4,statements:5,graphConfig:6,CLASS_DIAGRAM:7,NEWLINE:8,EOF:9,statement:10,classLabel:11,SQS:12,STR:13,SQE:14,namespaceName:15,alphaNumToken:16,classLiteralName:17,DOT:18,className:19,GENERICTYPE:20,relationStatement:21,LABEL:22,namespaceStatement:23,classStatement:24,memberStatement:25,annotationStatement:26,clickStatement:27,styleStatement:28,cssClassStatement:29,noteStatement:30,classDefStatement:31,direction:32,acc_title:33,acc_title_value:34,acc_descr:35,acc_descr_value:36,acc_descr_multiline_value:37,namespaceIdentifier:38,STRUCT_START:39,classStatements:40,STRUCT_STOP:41,NAMESPACE:42,classIdentifier:43,STYLE_SEPARATOR:44,members:45,ANNOTATION_START:46,ANNOTATION_END:47,CLASS:48,emptyBody:49,SPACE:50,MEMBER:51,SEPARATOR:52,relation:53,NOTE_FOR:54,noteText:55,NOTE:56,CLASSDEF:57,classList:58,stylesOpt:59,ALPHA:60,COMMA:61,direction_tb:62,direction_bt:63,direction_rl:64,direction_lr:65,relationType:66,lineType:67,AGGREGATION:68,EXTENSION:69,COMPOSITION:70,DEPENDENCY:71,LOLLIPOP:72,LINE:73,DOTTED_LINE:74,CALLBACK:75,LINK:76,LINK_TARGET:77,CLICK:78,CALLBACK_NAME:79,CALLBACK_ARGS:80,HREF:81,STYLE:82,CSSCLASS:83,style:84,styleComponent:85,NUM:86,COLON:87,UNIT:88,BRKT:89,PCT:90,commentToken:91,textToken:92,graphCodeTokens:93,textNoTagsToken:94,TAGSTART:95,TAGEND:96,"==":97,"--":98,DEFAULT:99,MINUS:100,keywords:101,UNICODE_TEXT:102,BQUOTE_STR:103,$accept:0,$end:1},terminals_:{2:"error",7:"CLASS_DIAGRAM",8:"NEWLINE",9:"EOF",12:"SQS",13:"STR",14:"SQE",18:"DOT",20:"GENERICTYPE",22:"LABEL",33:"acc_title",34:"acc_title_value",35:"acc_descr",36:"acc_descr_value",37:"acc_descr_multiline_value",39:"STRUCT_START",41:"STRUCT_STOP",42:"NAMESPACE",44:"STYLE_SEPARATOR",46:"ANNOTATION_START",47:"ANNOTATION_END",48:"CLASS",50:"SPACE",51:"MEMBER",52:"SEPARATOR",54:"NOTE_FOR",56:"NOTE",57:"CLASSDEF",60:"ALPHA",61:"COMMA",62:"direction_tb",63:"direction_bt",64:"direction_rl",65:"direction_lr",68:"AGGREGATION",69:"EXTENSION",70:"COMPOSITION",71:"DEPENDENCY",72:"LOLLIPOP",73:"LINE",74:"DOTTED_LINE",75:"CALLBACK",76:"LINK",77:"LINK_TARGET",78:"CLICK",79:"CALLBACK_NAME",80:"CALLBACK_ARGS",81:"HREF",82:"STYLE",83:"CSSCLASS",86:"NUM",87:"COLON",88:"UNIT",89:"BRKT",90:"PCT",93:"graphCodeTokens",95:"TAGSTART",96:"TAGEND",97:"==",98:"--",99:"DEFAULT",100:"MINUS",101:"keywords",102:"UNICODE_TEXT",103:"BQUOTE_STR"},productions_:[0,[3,1],[3,1],[4,1],[6,4],[5,1],[5,2],[5,3],[11,3],[15,1],[15,1],[15,3],[15,2],[19,1],[19,3],[19,1],[19,2],[19,2],[19,2],[10,1],[10,2],[10,1],[10,1],[10,1],[10,1],[10,1],[10,1],[10,1],[10,1],[10,1],[10,1],[10,2],[10,2],[10,1],[23,4],[23,5],[38,2],[38,3],[40,1],[40,2],[40,3],[40,1],[40,2],[40,3],[40,1],[40,2],[40,3],[24,1],[24,3],[24,4],[24,3],[24,6],[24,4],[24,7],[24,6],[43,2],[43,3],[49,0],[49,2],[49,2],[26,4],[45,1],[45,2],[25,1],[25,2],[25,1],[25,1],[21,3],[21,4],[21,4],[21,5],[30,3],[30,2],[31,3],[58,1],[58,3],[32,1],[32,1],[32,1],[32,1],[53,3],[53,2],[53,2],[53,1],[66,1],[66,1],[66,1],[66,1],[66,1],[67,1],[67,1],[27,3],[27,4],[27,3],[27,4],[27,4],[27,5],[27,3],[27,4],[27,4],[27,5],[27,4],[27,5],[27,5],[27,6],[28,3],[29,3],[59,1],[59,3],[84,1],[84,2],[85,1],[85,1],[85,1],[85,1],[85,1],[85,1],[85,1],[85,1],[85,1],[91,1],[91,1],[92,1],[92,1],[92,1],[92,1],[92,1],[92,1],[92,1],[94,1],[94,1],[94,1],[94,1],[16,1],[16,1],[16,1],[16,1],[17,1],[55,1]],performAction:f(function(o,h,p,l,C,e,$){var t=e.length-1;switch(C){case 8:this.$=e[t-1];break;case 9:case 10:case 13:case 15:this.$=e[t];break;case 11:case 14:this.$=e[t-2]+"."+e[t];break;case 12:case 16:this.$=e[t-1]+e[t];break;case 17:case 18:this.$=e[t-1]+"~"+e[t]+"~";break;case 19:l.addRelation(e[t]);break;case 20:e[t-1].titlLine truncated
|
||||
`+D.showPosition()+`
|
||||
Expecting `+pe.join(", ")+", got '"+(this.terminals_[B]||B)+"'":Oe="Parse error on line "+(he+1)+": Unexpected "+(B==We?"end of input":"'"+(this.terminals_[B]||B)+"'"),this.parseError(Oe,{text:D.match,token:this.terminals_[B]||B,line:D.yylineno,loc:ve,expected:pe})}if(L[0]instanceof Array&&L.length>1)throw new Error("Parse Error: multiple actions possible at state: "+P+", token: "+B);switch(L[0]){case 1:p.push(B),C.push(D.yytext),e.push(D.yylloc),p.push(L[1]),B=null,Ke=D.yyleng,t=D.yytext,he=D.yylineno,ve=D.yylloc;break;case 2:if(v=this.productions_[L[1]][1],G.$=C[C.length-v],G._$={first_line:e[e.length-(v||1)].first_line,last_line:e[e.length-1].last_line,first_column:e[e.length-(v||1)].first_column,last_column:e[e.length-1].last_column},et&&(G._$.range=[e[e.length-(v||1)].range[0],e[e.length-1].range[1]]),Ie=this.performAction.apply(G,[t,Ke,he,V.yy,L[1],C,e].concat($e)),typeof Ie<"u")return Ie;v&&(p=p.slice(0,-1*v*2),C=C.slice(0,-1*v),e=e.slice(0,-1*v)),p.push(this.productions_[L[1]][0]),C.push(G.$),e.push(G._$),je=$[p[p.length-2]][p[p.length-1]],p.push(je);break;case 3:return!0}}return!0},"parse")},Je=(function(){var O={EOF:1,parseError:f(function(h,p){if(this.yy.parser)this.yy.parser.parseError(h,p);else throw new Error(h)},"parseError"),setInput:f(function(o,h){return this.yy=h||this.yy||{},this._input=o,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:f(function(){var o=this._input[0];this.yytext+=o,this.yyleng++,this.offset++,this.match+=o,this.matched+=o;var h=o.match(/(?:\r\n?|\n).*/g);return h?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),o},"input"),unput:f(function(o){var h=o.length,p=o.split(/(?:\r\n?|\n)/g);this._input=o+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-h),this.offset-=h;var l=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),p.length-1&&(this.yylineno-=p.length-1);var C=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:p?(p.length===l.length?this.yylloc.first_column:0)+l[l.length-p.length].length-p[0].length:this.yylloc.first_column-h},this.options.ranges&&(this.yylloc.range=[C[0],C[0]+this.yyleng-h]),this.yyleng=this.yytext.length,this},"unput"),more:f(function(){return this._more=!0,this},"more"),reject:f(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true).
|
||||
`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:f(function(o){this.unput(this.match.slice(o))},"less"),pastInput:f(function(){var o=this.matched.substr(0,this.matched.length-this.match.length);return(o.length>20?"...":"")+o.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:f(function(){var o=this.match;return o.length<20&&(o+=this._input.substr(0,20-o.length)),(o.substr(0,20)+(o.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:f(function(){var o=this.pastInput(),h=new Array(o.length+1).join("-");return o+this.upcomingInput()+`
|
||||
`+h+"^"},"showPosition"),test_match:f(function(o,h){var p,l,C;if(this.options.backtrack_lexer&&(C={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(C.yylloc.range=this.yylloc.range.slice(0))),l=o[0].match(/(?:\r\n?|\n).*/g),l&&(this.yylineno+=l.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:l?l[l.length-1].length-l[l.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+o[0].length},this.yytext+=o[0],this.match+=o[0],this.matches=o,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(o[0].length),this.matched+=o[0],p=this.performAction.call(this,this.yy,this,h,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),p)return p;if(this._backtrack){for(var e in C)this[e]=C[e];return!1}return!1},"test_match"),next:f(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var o,h,p,l;this._more||(this.yytext="",this.match="");for(var C=this._currentRules(),e=0;e<C.length;e++)if(p=this._input.match(this.rules[C[e]]),p&&(!h||p[0].length>h[0].length)){if(h=p,l=e,this.options.backtrack_lexer){if(o=this.test_match(p,C[e]),o!==!1)return o;if(this._backtrack){h=!1;continue}else return!1}else if(!this.options.flex)break}return h?(o=this.test_match(h,C[l]),o!==!1?o:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text.
|
||||
`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:f(function(){var h=this.next();return h||this.lex()},"lex"),begin:f(function(h){this.conditionStack.push(h)},"begin"),popState:f(function(){var h=this.conditionStack.length-1;return h>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:f(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:f(function(h){return h=this.conditionStack.length-1-Math.abs(h||0),h>=0?this.conditionStack[h]:"INITIAL"},"topState"),pushState:f(function(h){this.begin(h)},"pushState"),stateStackSize:f(function(){return this.conditionStack.length},"stateStackSize"),options:{},performAction:f(function(h,p,l,C){switch(l){case 0:return 62;case 1:return 63;case 2:return 64;case 3:return 65;case 4:break;case 5:break;case 6:return this.begin("acc_title"),33;case 7:return this.popState(),"acc_title_value";case 8:return this.begin("acc_descr"),35;case 9:return this.popState(),"acc_descr_value";case 10:this.begin("acc_descr_multiline");break;case 11:this.popState();break;case 12:return"acc_descr_multiline_value";case 13:return 8;case 14:break;case 15:return 7;case 16:return 7;case 17:return"EDGE_STATE";case 18:this.begin("callback_name");break;case 19:this.popState();break;case 20:this.popState(),this.begin("callback_args");break;case 21:return 79;case 22:this.popState();break;case 23:return 80;case 24:this.popState();break;case 25:return"STR";case 26:this.begin("string");break;case 27:return 82;case 28:return 57;case 29:return this.begin("namespace"),42;case 30:return this.popState(),8;case 31:break;case 32:return this.begin("namespace-body"),39;case 33:this.popState(),this.less(0);break;case 34:return this.popState(),41;case 35:return"EOF_IN_STRUCT";case 36:return 8;case 37:break;case 38:return"EDGE_STATE";case 39:return this.begin("class"),48;case 40:return this.popState(),8;case 41:break;case 42:return this.popState(),this.popState(),41;case 43:return this.begin("class-body"),39;case 44:return this.popState(),41;case 45:return"EOF_IN_STRUCT";case 46:return"EDGE_STATE";case 47:return"OPEN_IN_STRUCT";case 48:break;case 49:return"MEMBER";case 50:return 83;case 51:return 75;case 52:return 76;case 53:return 78;case 54:return 54;case 55:return 56;case 56:return 46;case 57:return 47;case 58:return 81;case 59:this.popState();break;case 60:return"GENERICTYPE";case 61:this.begin("generic");break;case 62:this.popState();break;case 63:return"BQUOTE_STR";case 64:this.begin("bqstring");break;case 65:return 77;case 66:return 77;case 67:return 77;case 68:return 77;case 69:return 69;case 70:return 69;case 71:return 71;case 72:return 71;case 73:return 70;case 74:return 68;case 75:return 72;case 76:return 73;case 77:return 74;case 78:return 22;case 79:return 44;case 80:return 100;case 81:return 18;case 82:return"PLUS";case 83:return 87;case 84:return 61;case 85:return 89;case 86:return 89;case 87:return 90;case 88:return"EQUALS";case 89:return"EQUALS";case 90:return 60;case 91:return 12;case 92:return 14;case 93:return"PUNCTUATION";case 94:return 86;case 95:return 102;case 96:return 50;case 97:return 50;case 98:return 9}},"anonymous"),rules:[/^(?:.*direction\s+TB[^\n]*)/,/^(?:.*direction\s+BT[^\n]*)/,/^(?:.*direction\s+RL[^\n]*)/,/^(?:.*direction\s+LR[^\n]*)/,/^(?:%%(?!\{)*[^\n]*(\r?\n?)+)/,/^(?:%%[^\n]*(\r?\n)*)/,/^(?:accTitle\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*\{\s*)/,/^(?:[\}])/,/^(?:[^\}]*)/,/^(?:\s*(\r?\n)+)/,/^(?:\s+)/,/^(?:classDiagram-v2\b)/,/^(?:classDiagram\b)/,/^(?:\[\*\])/,/^(?:call[\s]+)/,/^(?:\([\s]*\))/,/^(?:\()/,/^(?:[^(]*)/,/^(?:\))/,/^(?:[^)]*)/,/^(?:["])/,/^(?:[^"]*)/,/^(?:["])/,/^(?:style\b)/,/^(?:classDef\b)/,/^(?:namespace\b)/,/^(?:\s*(\r?\n)+)/,/^(?:\s+)/,/^(?:[{])/,/^(?:[}])/,/^(?:[}])/,/^(?:$)/,/^(?:\s*(\r?\n)+)/,/^(?:\s+)/,/^(?:\[\*\])/,/^(?:class\b)/,/^(?:\s*(\r?\n)+)/,/^(?:\s+)/,/^(?:[}])/,/^(?:[{])/,/^(?:[}])/,/^(?:$)/,/^(?:\[\*\])/,/^(?:[{])/,/^(?:[\n])/,/^(?:[^{}\n]*)/,/^(?:cssClass\b)/,/^(?:callback\b)/,/^(?:link\b)/,/^(?:click\b)/,/^(?:note for\b)/,/^(?:note\b)/,/^(?:<<)/,/^(?:>>)/,/^(?:href\b)/,/^(?:[~])/,/^(?:[^~]*)/,/^(?:~)/,/^(?:[`])/,/^(?:[^`]+)/,/^(?:[`])/,/^(?:_self\b)/,/^(?:_blank\b)/,/^(?:_parent\b)/,/^(?:_top\b)/,/^(?:\s*<\|)/,/^(?:\s*\|>)/,/^(?:\s*>)/,/^(?:\s*<)/,/^(?:\s*\*)/,/^(?:\s*o\b)/,/^(?:\s*\(\))/,/^(?:--)/,/^(?:\.\.)/,/^(?::{1}[^:\n;]+)/,/^(?::{3})/,/^(?:-)/,/^(?:\.)/,/^(?:\+)/,/^(?::)/,/^(?:,)/,/^(?:#)/,/^(?:#)/,/^(?:%)/,/^(?:=)/,/^(?:=)/,/^(?:\w+)/,/^(?:\[)/,/^(?:\])/,/^(?:[!"#$%&'*+,-.`?\\/])/,/^(?:[0-9]+)/,/^(?:[\u00AA\u00B5\u00BA\u00C0-\u00D6\u00D8-\u00F6]|[\u00F8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377]|[\u037A-\u037D\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5]|[\u03F7-\u0481\u048A-\u0527\u0531-\u0556\u0559\u0561-\u0587\u05D0-\u05EA]|[\uLine truncated
|
||||
fill: ${s.nodeBorder||s.classText};
|
||||
stroke: none;
|
||||
font-family: ${s.fontFamily};
|
||||
font-size: 10px;
|
||||
|
||||
.title {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.cluster-label text {
|
||||
fill: ${s.titleColor};
|
||||
}
|
||||
.cluster-label span {
|
||||
color: ${s.titleColor};
|
||||
}
|
||||
.cluster-label span p {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.cluster rect {
|
||||
fill: ${s.clusterBkg};
|
||||
stroke: ${s.clusterBorder};
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
.cluster text {
|
||||
fill: ${s.titleColor};
|
||||
}
|
||||
|
||||
.cluster span {
|
||||
color: ${s.titleColor};
|
||||
}
|
||||
|
||||
.nodeLabel, .edgeLabel {
|
||||
color: ${s.classText};
|
||||
}
|
||||
|
||||
.noteLabel .nodeLabel, .noteLabel .edgeLabel {
|
||||
color: ${s.noteTextColor};
|
||||
}
|
||||
.edgeLabel .label rect {
|
||||
fill: ${s.mainBkg};
|
||||
}
|
||||
.label text {
|
||||
fill: ${s.classText};
|
||||
}
|
||||
|
||||
.labelBkg {
|
||||
background: ${s.mainBkg};
|
||||
}
|
||||
.edgeLabel .label span {
|
||||
background: ${s.mainBkg};
|
||||
}
|
||||
|
||||
.classTitle {
|
||||
font-weight: bolder;
|
||||
}
|
||||
.node rect,
|
||||
.node circle,
|
||||
.node ellipse,
|
||||
.node polygon,
|
||||
.node path {
|
||||
fill: ${s.mainBkg};
|
||||
stroke: ${s.nodeBorder};
|
||||
stroke-width: ${s.strokeWidth};
|
||||
}
|
||||
|
||||
|
||||
.divider {
|
||||
stroke: ${s.nodeBorder};
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
g.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
g.classGroup rect {
|
||||
fill: ${s.mainBkg};
|
||||
stroke: ${s.nodeBorder};
|
||||
}
|
||||
|
||||
g.classGroup line {
|
||||
stroke: ${s.nodeBorder};
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.classLabel .box {
|
||||
stroke: none;
|
||||
stroke-width: 0;
|
||||
fill: ${s.mainBkg};
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.classLabel .label {
|
||||
fill: ${s.nodeBorder};
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.relation {
|
||||
stroke: ${s.lineColor};
|
||||
stroke-width: ${s.strokeWidth};
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.dashed-line{
|
||||
stroke-dasharray: 3;
|
||||
}
|
||||
|
||||
.dotted-line{
|
||||
stroke-dasharray: 1 2;
|
||||
}
|
||||
|
||||
[id$="-compositionStart"], .composition {
|
||||
fill: ${s.lineColor} !important;
|
||||
stroke: ${s.lineColor} !important;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
[id$="-compositionEnd"], .composition {
|
||||
fill: ${s.lineColor} !important;
|
||||
stroke: ${s.lineColor} !important;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
[id$="-dependencyStart"], .dependency {
|
||||
fill: ${s.lineColor} !important;
|
||||
stroke: ${s.lineColor} !important;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
[id$="-dependencyEnd"], .dependency {
|
||||
fill: ${s.lineColor} !important;
|
||||
stroke: ${s.lineColor} !important;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
[id$="-extensionStart"], .extension {
|
||||
fill: transparent !important;
|
||||
stroke: ${s.lineColor} !important;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
[id$="-extensionEnd"], .extension {
|
||||
fill: transparent !important;
|
||||
stroke: ${s.lineColor} !important;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
[id$="-aggregationStart"], .aggregation {
|
||||
fill: transparent !important;
|
||||
stroke: ${s.lineColor} !important;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
[id$="-aggregationEnd"], .aggregation {
|
||||
fill: transparent !important;
|
||||
stroke: ${s.lineColor} !important;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
[id$="-lollipopStart"], .lollipop {
|
||||
fill: ${s.mainBkg} !important;
|
||||
stroke: ${s.lineColor} !important;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
[id$="-lollipopEnd"], .lollipop {
|
||||
fill: ${s.mainBkg} !important;
|
||||
stroke: ${s.lineColor} !important;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.edgeTerminals {
|
||||
font-size: 11px;
|
||||
line-height: initial;
|
||||
}
|
||||
|
||||
.classTitleText {
|
||||
text-anchor: middle;
|
||||
font-size: 18px;
|
||||
fill: ${s.textColor};
|
||||
}
|
||||
|
||||
.edgeLabel[data-look="neo"] {
|
||||
background-color: ${s.edgeLabelBackground};
|
||||
p {
|
||||
background-color: ${s.edgeLabelBackground};
|
||||
}
|
||||
rect {
|
||||
opacity: 0.5;
|
||||
background-color: ${s.edgeLabelBackground};
|
||||
fill: ${s.edgeLabelBackground};
|
||||
}
|
||||
text-align: center;
|
||||
}
|
||||
${st()}
|
||||
`,"getStyles"),Nt=Ct,bt=f((s,i="TB")=>{if(!s.doc)return i;let a=i;for(const n of s.doc)n.stmt==="dir"&&(a=n.value);return a},"getDir"),kt=f(function(s,i){return i.db.getClasses()},"getClasses"),Et=f(async function(s,i,a,n){we.info("REF0:"),we.info("Drawing class diagram (v3)",i);const{securityLevel:r,state:c,layout:u}=F();n.db.setDiagramId(i);const d=n.db.getData(),m=it(i,r);d.type=n.type,d.layoutAlgorithm=nt(u),d.nodeSpacing=c?.nodeSpacing||50,d.rankSpacing=c?.rankSpacing||50,d.markers=["aggregation","extension","composition","dependency","lollipop"],d.diagramId=i,await rt(d,m);const g=8;Ve.insertTitle(m,"classDiagramTitleText",c?.titleTopMargin??25,n.db.getDiagramTitle()),at(m,g,"classDiagram",c?.useMaxWidth??!0)},"draw"),Lt={getClasses:kt,draw:Et,getDir:bt};export{St as C,_t as a,Lt as c,Nt as s};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as a,j as o}from"./mermaid.core.js";var g=a((t,e)=>{let n;return e==="sandbox"&&(n=o("#i"+t)),(e==="sandbox"?o(n.nodes()[0].contentDocument.body):o("body")).select(`[id="${t}"]`)},"getDiagramElement");export{g};
|
||||
@@ -0,0 +1 @@
|
||||
import{s as a,c as s,a as e,C as t}from"./chunk-TICWLB2K.js";import{_ as i}from"./mermaid.core.js";import"./chunk-5VM5RSS4.js";import"./chunk-XXDRQBXY.js";import"./chunk-POPQ4Y6H.js";import"./chunk-F27PBJKO.js";import"./index.js";var n={parser:e,get db(){return new t},renderer:s,styles:a,init:i(r=>{r.class||(r.class={}),r.class.arrowMarkerAbsolute=r.arrowMarkerAbsolute},"init")};export{n as diagram};
|
||||
@@ -0,0 +1 @@
|
||||
import{s as a,c as s,a as e,C as t}from"./chunk-TICWLB2K.js";import{_ as i}from"./mermaid.core.js";import"./chunk-5VM5RSS4.js";import"./chunk-XXDRQBXY.js";import"./chunk-POPQ4Y6H.js";import"./chunk-F27PBJKO.js";import"./index.js";var n={parser:e,get db(){return new t},renderer:s,styles:a,init:i(r=>{r.class||(r.class={}),r.class.arrowMarkerAbsolute=r.arrowMarkerAbsolute},"init")};export{n as diagram};
|
||||
@@ -1 +1 @@
|
||||
var b;(function(s){s.assertEqual=n=>{};function e(n){}s.assertIs=e;function t(n){throw new Error}s.assertNever=t,s.arrayToEnum=n=>{const i={};for(const a of n)i[a]=a;return i},s.getValidEnumValues=n=>{const i=s.objectKeys(n).filter(o=>typeof n[n[o]]!="number"),a={};for(const o of i)a[o]=n[o];return s.objectValues(a)},s.objectValues=n=>s.objectKeys(n).map(function(i){return n[i]}),s.objectKeys=typeof Object.keys=="function"?n=>Object.keys(n):n=>{const i=[];for(const a in n)Object.prototype.hasOwnProperty.call(n,a)&&i.push(a);return i},s.find=(n,i)=>{for(const a of n)if(i(a))return a},s.isInteger=typeof Number.isInteger=="function"?n=>Number.isInteger(n):n=>typeof n=="number"&&Number.isFinite(n)&&Math.floor(n)===n;function r(n,i=" | "){return n.map(a=>typeof a=="string"?`'${a}'`:a).join(i)}s.joinValues=r,s.jsonStringifyReplacer=(n,i)=>typeof i=="bigint"?i.toString():i})(b||(b={}));var Ie;(function(s){s.mergeShapes=(e,t)=>({...e,...t})})(Ie||(Ie={}));const f=b.arrayToEnum(["string","nan","number","integer","float","boolean","date","bigint","symbol","function","undefined","null","array","object","unknown","promise","void","never","map","set"]),I=s=>{switch(typeof s){case"undefined":return f.undefined;case"string":return f.string;case"number":return Number.isNaN(s)?f.nan:f.number;case"boolean":return f.boolean;case"function":return f.function;case"bigint":return f.bigint;case"symbol":return f.symbol;case"object":return Array.isArray(s)?f.array:s===null?f.null:s.then&&typeof s.then=="function"&&s.catch&&typeof s.catch=="function"?f.promise:typeof Map<"u"&&s instanceof Map?f.map:typeof Set<"u"&&s instanceof Set?f.set:typeof Date<"u"&&s instanceof Date?f.date:f.object;default:return f.unknown}},c=b.arrayToEnum(["invalid_type","invalid_literal","custom","invalid_union","invalid_union_discriminator","invalid_enum_value","unrecognized_keys","invalid_arguments","invalid_return_type","invalid_date","invalid_string","too_small","too_big","invalid_intersection_types","not_multiple_of","not_finite"]);class T extends Error{get errors(){return this.issues}constructor(e){super(),this.issues=[],this.addIssue=r=>{this.issues=[...this.issues,r]},this.addIssues=(r=[])=>{this.issues=[...this.issues,...r]};const t=new.target.prototype;Object.setPrototypeOf?Object.setPrototypeOf(this,t):this.__proto__=t,this.name="ZodError",this.issues=e}format(e){const t=e||function(i){return i.message},r={_errors:[]},n=i=>{for(const a of i.issues)if(a.code==="invalid_union")a.unionErrors.map(n);else if(a.code==="invalid_return_type")n(a.returnTypeError);else if(a.code==="invalid_arguments")n(a.argumentsError);else if(a.path.length===0)r._errors.push(t(a));else{let o=r,l=0;for(;l<a.path.length;){const d=a.path[l];l===a.path.length-1?(o[d]=o[d]||{_errors:[]},o[d]._errors.push(t(a))):o[d]=o[d]||{_errors:[]},o=o[d],l++}}};return n(this),r}static assert(e){if(!(e instanceof T))throw new Error(`Not a ZodError: ${e}`)}toString(){return this.message}get message(){return JSON.stringify(this.issues,b.jsonStringifyReplacer,2)}get isEmpty(){return this.issues.length===0}flatten(e=t=>t.message){const t={},r=[];for(const n of this.issues)if(n.path.length>0){const i=n.path[0];t[i]=t[i]||[],t[i].push(e(n))}else r.push(e(n));return{formErrors:r,fieldErrors:t}}get formErrors(){return this.flatten()}}T.create=s=>new T(s);const _e=(s,e)=>{let t;switch(s.code){case c.invalid_type:s.received===f.undefined?t="Required":t=`Expected ${s.expected}, received ${s.received}`;break;case c.invalid_literal:t=`Invalid literal value, expected ${JSON.stringify(s.expected,b.jsonStringifyReplacer)}`;break;case c.unrecognized_keys:t=`Unrecognized key(s) in object: ${b.joinValues(s.keys,", ")}`;break;case c.invalid_union:t="Invalid input";break;case c.invalid_union_discriminator:t=`Invalid discriminator value. Expected ${b.joinValues(s.options)}`;break;case c.invalid_enum_value:t=`Invalid enum value. Expected ${b.joinValues(s.options)}, received '${s.received}'`;break;case c.invalid_arguments:t="Invalid function arguments";break;case c.invalid_return_type:t="Invalid function return type";break;case c.invalid_date:t="Invalid date";break;case c.invalid_string:typeof s.validation=="object"?"includes"in s.validation?(t=`Invalid input: must include "${s.validation.includes}"`,typeof s.validation.position=="number"&&(t=`${t} at one or more positions greater than or equal to ${s.validation.position}`)):"startsWith"in s.validation?t=`Invalid input: must start with "${s.validation.startsWith}"`:"endsWith"in s.validation?t=`Invalid input: must end with "${s.validation.endsWith}"`:b.assertNever(s.validation):s.validation!=="regex"?t=`Invalid ${s.validation}`:t="Invalid";break;case c.too_small:s.type==="array"?t=`Array must contain ${s.exact?"exactly":s.inclusive?"at least":"more than"} ${s.minimum} element(s)`:s.type==="string"?t=`String must contain ${s.exact?"exactly":s.inclusive?"at least":"over"} ${s.minimum} character(s)`:s.type==="number"?t=`Number must be ${s.exact?"exactly equal to ":s.iLine truncated
|
||||
var b;(function(s){s.assertEqual=n=>{};function e(n){}s.assertIs=e;function t(n){throw new Error}s.assertNever=t,s.arrayToEnum=n=>{const i={};for(const a of n)i[a]=a;return i},s.getValidEnumValues=n=>{const i=s.objectKeys(n).filter(o=>typeof n[n[o]]!="number"),a={};for(const o of i)a[o]=n[o];return s.objectValues(a)},s.objectValues=n=>s.objectKeys(n).map(function(i){return n[i]}),s.objectKeys=typeof Object.keys=="function"?n=>Object.keys(n):n=>{const i=[];for(const a in n)Object.prototype.hasOwnProperty.call(n,a)&&i.push(a);return i},s.find=(n,i)=>{for(const a of n)if(i(a))return a},s.isInteger=typeof Number.isInteger=="function"?n=>Number.isInteger(n):n=>typeof n=="number"&&Number.isFinite(n)&&Math.floor(n)===n;function r(n,i=" | "){return n.map(a=>typeof a=="string"?`'${a}'`:a).join(i)}s.joinValues=r,s.jsonStringifyReplacer=(n,i)=>typeof i=="bigint"?i.toString():i})(b||(b={}));var Ie;(function(s){s.mergeShapes=(e,t)=>({...e,...t})})(Ie||(Ie={}));const f=b.arrayToEnum(["string","nan","number","integer","float","boolean","date","bigint","symbol","function","undefined","null","array","object","unknown","promise","void","never","map","set"]),I=s=>{switch(typeof s){case"undefined":return f.undefined;case"string":return f.string;case"number":return Number.isNaN(s)?f.nan:f.number;case"boolean":return f.boolean;case"function":return f.function;case"bigint":return f.bigint;case"symbol":return f.symbol;case"object":return Array.isArray(s)?f.array:s===null?f.null:s.then&&typeof s.then=="function"&&s.catch&&typeof s.catch=="function"?f.promise:typeof Map<"u"&&s instanceof Map?f.map:typeof Set<"u"&&s instanceof Set?f.set:typeof Date<"u"&&s instanceof Date?f.date:f.object;default:return f.unknown}},c=b.arrayToEnum(["invalid_type","invalid_literal","custom","invalid_union","invalid_union_discriminator","invalid_enum_value","unrecognized_keys","invalid_arguments","invalid_return_type","invalid_date","invalid_string","too_small","too_big","invalid_intersection_types","not_multiple_of","not_finite"]);class T extends Error{get errors(){return this.issues}constructor(e){super(),this.issues=[],this.addIssue=r=>{this.issues=[...this.issues,r]},this.addIssues=(r=[])=>{this.issues=[...this.issues,...r]};const t=new.target.prototype;Object.setPrototypeOf?Object.setPrototypeOf(this,t):this.__proto__=t,this.name="ZodError",this.issues=e}format(e){const t=e||function(i){return i.message},r={_errors:[]},n=i=>{for(const a of i.issues)if(a.code==="invalid_union")a.unionErrors.map(n);else if(a.code==="invalid_return_type")n(a.returnTypeError);else if(a.code==="invalid_arguments")n(a.argumentsError);else if(a.path.length===0)r._errors.push(t(a));else{let o=r,l=0;for(;l<a.path.length;){const d=a.path[l];l===a.path.length-1?(o[d]=o[d]||{_errors:[]},o[d]._errors.push(t(a))):o[d]=o[d]||{_errors:[]},o=o[d],l++}}};return n(this),r}static assert(e){if(!(e instanceof T))throw new Error(`Not a ZodError: ${e}`)}toString(){return this.message}get message(){return JSON.stringify(this.issues,b.jsonStringifyReplacer,2)}get isEmpty(){return this.issues.length===0}flatten(e=t=>t.message){const t={},r=[];for(const n of this.issues)if(n.path.length>0){const i=n.path[0];t[i]=t[i]||[],t[i].push(e(n))}else r.push(e(n));return{formErrors:r,fieldErrors:t}}get formErrors(){return this.flatten()}}T.create=s=>new T(s);const _e=(s,e)=>{let t;switch(s.code){case c.invalid_type:s.received===f.undefined?t="Required":t=`Expected ${s.expected}, received ${s.received}`;break;case c.invalid_literal:t=`Invalid literal value, expected ${JSON.stringify(s.expected,b.jsonStringifyReplacer)}`;break;case c.unrecognized_keys:t=`Unrecognized key(s) in object: ${b.joinValues(s.keys,", ")}`;break;case c.invalid_union:t="Invalid input";break;case c.invalid_union_discriminator:t=`Invalid discriminator value. Expected ${b.joinValues(s.options)}`;break;case c.invalid_enum_value:t=`Invalid enum value. Expected ${b.joinValues(s.options)}, received '${s.received}'`;break;case c.invalid_arguments:t="Invalid function arguments";break;case c.invalid_return_type:t="Invalid function return type";break;case c.invalid_date:t="Invalid date";break;case c.invalid_string:typeof s.validation=="object"?"includes"in s.validation?(t=`Invalid input: must include "${s.validation.includes}"`,typeof s.validation.position=="number"&&(t=`${t} at one or more positions greater than or equal to ${s.validation.position}`)):"startsWith"in s.validation?t=`Invalid input: must start with "${s.validation.startsWith}"`:"endsWith"in s.validation?t=`Invalid input: must end with "${s.validation.endsWith}"`:b.assertNever(s.validation):s.validation!=="regex"?t=`Invalid ${s.validation}`:t="Invalid";break;case c.too_small:s.type==="array"?t=`Array must contain ${s.exact?"exactly":s.inclusive?"at least":"more than"} ${s.minimum} element(s)`:s.type==="string"?t=`String must contain ${s.exact?"exactly":s.inclusive?"at least":"over"} ${s.minimum} character(s)`:s.type==="number"?t=`Number must be ${s.exact?"exactly equal to ":s.iLine truncated
|
||||
Loaded 100 of 185 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user