mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 05:56:17 -10:00
Compare commits
205
Commits
No files matched your search
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"templateRepo": "MervinPraison/github-automation-template",
|
||||
"stack": "claude",
|
||||
"profile": "sdk",
|
||||
"mode": "hub",
|
||||
"hubRef": "v1.1.8",
|
||||
"templateVersion": "1.1.7",
|
||||
"installedAt": "2026-09-07T10:02:11Z"
|
||||
}
|
||||
@@ -32,4 +32,13 @@ const comment = ciFix.buildCiFixComment({
|
||||
});
|
||||
assert('comment mentions product guardrails', comment.includes('Product guardrails'));
|
||||
|
||||
const skip = ciFix.shouldSkipCiFix({
|
||||
comments: [],
|
||||
headSha: 'abc1234567890abcdef1234567890abcdef12',
|
||||
labels: [],
|
||||
claudeInProgress: false,
|
||||
failedChecks: [{ name: 'python', workflow: 'CI' }],
|
||||
});
|
||||
assert('ci fix without FINAL review allowed', !skip.skip);
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -46,10 +46,9 @@ function hasRecentCiFixComment(comments, headSha) {
|
||||
});
|
||||
}
|
||||
|
||||
function shouldSkipCiFix({ comments, headSha, labels, hasFinal, claudeInProgress, failedChecks }) {
|
||||
function shouldSkipCiFix({ comments, headSha, labels, claudeInProgress, failedChecks }) {
|
||||
if (!failedChecks.length) return { skip: true, reason: 'no failed checks' };
|
||||
if (labels.includes(CI_FIX_LABEL)) return { skip: true, reason: 'ci fix pending' };
|
||||
if (!hasFinal) return { skip: true, reason: 'awaiting final claude review trigger' };
|
||||
if (claudeInProgress) return { skip: true, reason: 'claude in progress' };
|
||||
if (hasCiFixCommentForSha(comments, headSha)) {
|
||||
return { skip: true, reason: 'already commented for this sha' };
|
||||
@@ -159,12 +158,14 @@ function buildCiFixComment({ headSha, failedChecks, failureSummaries }) {
|
||||
'',
|
||||
'**Product guardrails (AGENTS.md):**',
|
||||
'- Do not disturb PraisonAIUI contracts or frontend/backend layering to accommodate scope creep.',
|
||||
'- Core agent runtime belongs in praisonaiagents; agent-callable tools belong in PraisonAI-Tools — not here.',
|
||||
'- Prefer existing YAML config, plugins, and feature modules over new server.py/create_app() params or knobs with no live consumer.',
|
||||
'- Tests must continue to guard backward compatibility and UX behaviour — passing CI by lowering test standards is not acceptable.',
|
||||
'- If the feature does not genuinely add product value, recommend reverting or narrowing scope instead of patching around failures.',
|
||||
'',
|
||||
'## What to do',
|
||||
'1. State your verdict: **legitimate fix**, **regression fix**, or **needs human review** — and why (1–3 sentences).',
|
||||
'2. Fix root cause with **minimal changes**; never bloat the Agent class with extra params.',
|
||||
'2. Fix root cause with **minimal changes**; never bloat server.py or create_app() with extra params.',
|
||||
exampleTest
|
||||
? `3. Run failing tests locally, e.g. \`pytest ${exampleTest} -q\`, plus any related tests touched by the PR.`
|
||||
: '3. Run failing tests locally with targeted pytest, plus any related tests touched by the PR.',
|
||||
@@ -221,7 +222,6 @@ async function maybeTriggerCiFixClaude(github, owner, repo, prNumber, core, opts
|
||||
return { skipped: true, reason: 'no failed checks yet (may be pending)' };
|
||||
}
|
||||
|
||||
const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(ctx.comments);
|
||||
const claudeInProgress = await mergeGate.hasInProgressClaudeAssistant(
|
||||
github, owner, repo, prNumber
|
||||
);
|
||||
@@ -229,7 +229,6 @@ async function maybeTriggerCiFixClaude(github, owner, repo, prNumber, core, opts
|
||||
comments: ctx.comments,
|
||||
headSha,
|
||||
labels,
|
||||
hasFinal,
|
||||
claudeInProgress,
|
||||
failedChecks: failedRuns,
|
||||
});
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
module.exports = {
|
||||
repoFullName: 'MervinPraison/PraisonAIUI',
|
||||
gitUser: 'MervinPraison',
|
||||
gitEmail: '[email protected]',
|
||||
triggerLogins: ['MervinPraison', 'github-actions[bot]'],
|
||||
allowedTriageBots: ['praisonai-triage-agent[bot]'],
|
||||
productPathPrefixes: ['src/praisonaiui/', 'src/frontend/', 'tests/'],
|
||||
sensitivePathPatterns: [
|
||||
/^\.github\/workflows\//,
|
||||
@@ -21,8 +25,27 @@ module.exports = {
|
||||
'SCOPE: Focus ONLY on PraisonAIUI (src/praisonaiui, src/frontend, tests, docs). '
|
||||
+ 'Do NOT expand into praisonaiagents or the monorepo unless the PR explicitly requires it.',
|
||||
finalClaudeProductValue:
|
||||
'4. Product value: review whether the change genuinely adds PraisonAIUI UX value and correct '
|
||||
+ 'layering (UI/frontend here; core agent logic belongs in praisonaiagents). Reject scope creep.',
|
||||
'4. Product value: review in depth whether the change genuinely adds PraisonAIUI UX value — '
|
||||
+ 'never add features for the sake of adding them. It must strengthen the UI (simpler, '
|
||||
+ 'more user-friendly, robust). If it does not clearly add value, request changes or recommend '
|
||||
+ 'rejecting/closing rather than merging scope creep.\n'
|
||||
+ '5. Layering: UI/frontend/server features here; core agent runtime belongs in praisonaiagents '
|
||||
+ '(PraisonAI monorepo). Agent-callable tools → PraisonAI-Tools. Do not pull monorepo or SDK '
|
||||
+ 'scope into this repo unless the PR explicitly requires it.\n'
|
||||
+ '6. Do not bloat server.py or create_app() with new params — prefer existing YAML config, '
|
||||
+ 'plugins, and feature modules; only add params if absolutely required. Reject knobs/exports '
|
||||
+ 'with no live consumer.',
|
||||
mergeGateProductValue:
|
||||
'Confirm product value gate: change strengthens PraisonAIUI UX (no scope creep). The aim is a '
|
||||
+ 'LIGHTWEIGHT AND POWERFUL package — BLOCK changes that add params/modules/exports duplicating '
|
||||
+ 'existing UI capabilities (YAML config, plugins, feature modules, existing server hooks), or '
|
||||
+ 'add knobs/fields with no live consumer, i.e. scope creep for the sake of it.',
|
||||
mergeGateLayering:
|
||||
'Layering routing: BLOCK if core agent logic was added here instead of praisonaiagents; BLOCK if '
|
||||
+ 'agent-callable tools were added here instead of PraisonAI-Tools.',
|
||||
mergeGateServerBloat:
|
||||
'Do not bloat server.py or create_app() with additional params — only if absolutely required; '
|
||||
+ 'prefer existing config/plugin patterns.',
|
||||
agentPyChecks: false,
|
||||
reviewBotLogins: [
|
||||
'coderabbitai[bot]',
|
||||
|
||||
@@ -205,7 +205,7 @@ const headAfterRebase = conflictIso(-21 * 60 * 1000);
|
||||
const rebaseComments = [conflictTrigger, rebaseDone, finalAfterRebase];
|
||||
assert('conflict blocks before rebase done', mg.hasRecentConflictComment([conflictTrigger], headAfterRebase));
|
||||
assert('conflict clears after rebase + FINAL on HEAD', !mg.hasRecentConflictComment(rebaseComments, headAfterRebase));
|
||||
assert('conflict still blocks without FINAL on HEAD', mg.hasRecentConflictComment(
|
||||
assert('conflict clears after rebase without FINAL on HEAD', !mg.hasRecentConflictComment(
|
||||
[conflictTrigger, rebaseDone],
|
||||
headAfterRebase
|
||||
));
|
||||
|
||||
@@ -67,7 +67,11 @@ function isClaudeTriggerNoise(c) {
|
||||
|
||||
function isClaudeFinalReplyComment(c) {
|
||||
const login = (c.user?.login || '').toLowerCase();
|
||||
if (!login.includes('praisonai-triage')) return false;
|
||||
const fromAutomation =
|
||||
login.includes('praisonai-triage') ||
|
||||
login.includes('github-actions') ||
|
||||
AUTO_ACTORS.some((a) => a.toLowerCase() === login);
|
||||
if (!fromAutomation) return false;
|
||||
return (c.body || '').includes('Claude finished');
|
||||
}
|
||||
|
||||
@@ -89,15 +93,47 @@ function isConflictRebaseTriggerComment(c) {
|
||||
|
||||
function isConflictRebaseCompletionComment(c) {
|
||||
const login = (c.user?.login || '').toLowerCase();
|
||||
if (!login.includes('praisonai-triage') && !login.includes('github-actions')) return false;
|
||||
const fromAutomation =
|
||||
login.includes('praisonai-triage') ||
|
||||
login.includes('github-actions') ||
|
||||
AUTO_ACTORS.some((a) => a.toLowerCase() === login);
|
||||
if (!fromAutomation) return false;
|
||||
const body = (c.body || '').toLowerCase();
|
||||
return (
|
||||
body.includes('rebase complete') ||
|
||||
body.includes('rebase onto') ||
|
||||
body.includes('force-pushed') ||
|
||||
(body.includes('conflict') && body.includes('resolved'))
|
||||
);
|
||||
}
|
||||
|
||||
/** HEAD landed after conflict rebase but no FINAL @claude since that push. */
|
||||
function needsFinalReReviewAfterConflictRebase(comments, headPushedAt) {
|
||||
if (!headPushedAt) return false;
|
||||
const headTime = new Date(headPushedAt).getTime();
|
||||
const conflictTriggers = comments.filter(isConflictRebaseTriggerComment);
|
||||
if (conflictTriggers.length === 0) return false;
|
||||
|
||||
const latestConflict = conflictTriggers.reduce((a, b) =>
|
||||
new Date(a.created_at) > new Date(b.created_at) ? a : b
|
||||
);
|
||||
const conflictTime = new Date(latestConflict.created_at).getTime();
|
||||
if (headTime <= conflictTime) return false;
|
||||
|
||||
const rebaseDone = comments.some(
|
||||
(c) =>
|
||||
new Date(c.created_at).getTime() > conflictTime && isConflictRebaseCompletionComment(c)
|
||||
);
|
||||
if (!rebaseDone) return false;
|
||||
|
||||
const finalsAfterHead = comments.filter(
|
||||
(c) =>
|
||||
isFinalClaudeTriggerComment(c) &&
|
||||
new Date(c.created_at).getTime() >= headTime - 60000
|
||||
);
|
||||
return finalsAfterHead.length === 0;
|
||||
}
|
||||
|
||||
function conflictRebaseQuiescent(comments, headPushedAt) {
|
||||
const conflictTriggers = comments.filter(isConflictRebaseTriggerComment);
|
||||
if (conflictTriggers.length === 0) return true;
|
||||
@@ -113,7 +149,7 @@ function conflictRebaseQuiescent(comments, headPushedAt) {
|
||||
);
|
||||
if (!rebaseDone) return false;
|
||||
|
||||
return finalClaudeCompletedOnSha(comments, headPushedAt);
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasRecentConflictComment(comments, headPushedAt = null) {
|
||||
@@ -238,6 +274,9 @@ function shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusherLogin =
|
||||
if (headPusherLogin && isClaudeAutomationLogin(headPusherLogin)) {
|
||||
return { skip: true, reason: 'head pushed by Claude automation' };
|
||||
}
|
||||
if (needsFinalReReviewAfterConflictRebase(comments, headPushedAt)) {
|
||||
return { skip: false, reason: '' };
|
||||
}
|
||||
if (isPushSoonAfterLatestFinal(comments, headPushedAt)) {
|
||||
return {
|
||||
skip: true,
|
||||
@@ -846,6 +885,7 @@ module.exports = {
|
||||
hasRecentConflictComment,
|
||||
isConflictRebaseTriggerComment,
|
||||
isConflictRebaseCompletionComment,
|
||||
needsFinalReReviewAfterConflictRebase,
|
||||
conflictRebaseQuiescent,
|
||||
hasHumanChangesRequested,
|
||||
hasAnyChangesRequested,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env node
|
||||
const chain = require('./pr-review-chain.js');
|
||||
|
||||
let failed = 0;
|
||||
function assert(name, cond) {
|
||||
if (!cond) {
|
||||
console.error('FAIL:', name);
|
||||
failed++;
|
||||
} else {
|
||||
console.log('ok:', name);
|
||||
}
|
||||
}
|
||||
|
||||
const coderabbit = {
|
||||
user: { login: 'coderabbitai[bot]' },
|
||||
body: '<!-- summarize by coderabbit.ai --> summary',
|
||||
created_at: '2026-07-02T07:16:00Z',
|
||||
};
|
||||
const greptile = {
|
||||
user: { login: 'greptile-apps[bot]' },
|
||||
body: '<h3>Greptile Summary</h3><p>Looks good</p>',
|
||||
created_at: '2026-07-02T07:19:00Z',
|
||||
};
|
||||
const copilotKick = {
|
||||
user: { login: 'MervinPraison' },
|
||||
body: '@copilot Do a thorough review',
|
||||
created_at: '2026-07-02T07:20:00Z',
|
||||
};
|
||||
const copilotReply = {
|
||||
user: { login: 'copilot-swe-agent' },
|
||||
body: 'Copilot review here',
|
||||
created_at: '2026-07-02T07:22:00Z',
|
||||
};
|
||||
|
||||
assert('prior not ready without greptile', !chain.priorReviewersReady([coderabbit]).ready);
|
||||
assert('prior ready with coderabbit + greptile', chain.priorReviewersReady([coderabbit, greptile]).ready);
|
||||
assert('copilot not ready before trigger', !chain.copilotReviewReady([coderabbit, greptile]).ready);
|
||||
assert(
|
||||
'copilot ready after reply',
|
||||
chain.copilotReviewReady([coderabbit, greptile, copilotKick, copilotReply]).ready
|
||||
);
|
||||
assert(
|
||||
'claude not ready before copilot',
|
||||
!chain.claudeFinalReady([coderabbit, greptile]).ready
|
||||
);
|
||||
assert(
|
||||
'claude ready after full chain',
|
||||
chain.claudeFinalReady([coderabbit, greptile, copilotKick, copilotReply]).ready
|
||||
);
|
||||
assert(
|
||||
'claude timeout fallback when copilot triggered but silent',
|
||||
chain.claudeFinalReady([coderabbit, greptile, copilotKick], [], { allowCopilotTimeout: true }).ready
|
||||
);
|
||||
assert(
|
||||
'no timeout fallback without copilot trigger',
|
||||
!chain.claudeFinalReady([coderabbit, greptile], [], { allowCopilotTimeout: true }).ready
|
||||
);
|
||||
assert(
|
||||
'skipCopilot proceeds after prior reviewers',
|
||||
chain.claudeFinalReady([coderabbit, greptile], [], { skipCopilot: true }).ready
|
||||
);
|
||||
|
||||
const oldPr = new Date(Date.now() - 31 * 60 * 1000).toISOString();
|
||||
assert(
|
||||
'prior not ready without coderabbit before timeout',
|
||||
!chain.priorReviewersReady([greptile], [], { prCreatedAt: new Date().toISOString(), requiredWaitMs: 30 * 60 * 1000 }).ready
|
||||
);
|
||||
assert(
|
||||
'prior timeout when coderabbit silent after 30m',
|
||||
chain.priorReviewersReady([greptile], [], { prCreatedAt: oldPr, requiredWaitMs: 30 * 60 * 1000 }).ready
|
||||
);
|
||||
assert(
|
||||
'claude FINAL timeout when required reviewer silent and no prior FINAL',
|
||||
chain.claudeFinalReady([greptile], [], { prCreatedAt: oldPr, requiredWaitMs: 30 * 60 * 1000 }).ready
|
||||
);
|
||||
assert(
|
||||
'claude FINAL timeout auto-skips copilot without explicit skipCopilot',
|
||||
chain.claudeFinalReady([greptile], [], { prCreatedAt: oldPr, requiredWaitMs: 30 * 60 * 1000 }).copilotSkipped
|
||||
);
|
||||
const finalClaude = {
|
||||
user: { login: 'github-actions[bot]' },
|
||||
body: '@claude You are the FINAL architecture reviewer for this PR.',
|
||||
created_at: '2026-07-02T07:25:00Z',
|
||||
};
|
||||
assert(
|
||||
'claude FINAL not re-posted when already triggered',
|
||||
!chain.claudeFinalReady([greptile, finalClaude], [], { prCreatedAt: oldPr, requiredWaitMs: 0 }).ready
|
||||
);
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -16,6 +16,7 @@ const CLAUDE_TRIGGER_LOGINS = new Set(['MervinPraison', 'github-actions[bot]']);
|
||||
const REQUIRED_PRIOR = ['coderabbit', 'greptile'];
|
||||
const OPTIONAL_PRIOR = ['qodo', 'gemini'];
|
||||
const OPTIONAL_PRIOR_WAIT_MS = 20 * 60 * 1000;
|
||||
const REQUIRED_PRIOR_WAIT_MS = 30 * 60 * 1000;
|
||||
|
||||
function loginOf(item) {
|
||||
return (item?.user?.login || '').toLowerCase();
|
||||
@@ -75,11 +76,23 @@ function priorReviewerStatus(comments, reviews = []) {
|
||||
|
||||
function priorReviewersReady(comments, reviews = [], options = {}) {
|
||||
const waitMs = options.optionalWaitMs ?? OPTIONAL_PRIOR_WAIT_MS;
|
||||
const requiredWaitMs = options.requiredWaitMs ?? REQUIRED_PRIOR_WAIT_MS;
|
||||
const prCreatedAt = options.prCreatedAt || null;
|
||||
const status = priorReviewerStatus(comments, reviews);
|
||||
|
||||
const missingRequired = REQUIRED_PRIOR.filter((id) => !status[id]);
|
||||
if (missingRequired.length) {
|
||||
if (prCreatedAt) {
|
||||
const age = Date.now() - new Date(prCreatedAt).getTime();
|
||||
if (age >= requiredWaitMs) {
|
||||
return {
|
||||
ready: true,
|
||||
reason: `required reviewer timeout (${missingRequired.join(', ')} silent after ${Math.round(requiredWaitMs / 60000)}m)`,
|
||||
status,
|
||||
requiredTimeout: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { ready: false, reason: `waiting for ${missingRequired.join(', ')}`, status };
|
||||
}
|
||||
|
||||
@@ -157,8 +170,13 @@ function claudeFinalReady(comments, reviews = [], options = {}) {
|
||||
if (!prior.ready) {
|
||||
return { ready: false, reason: prior.reason };
|
||||
}
|
||||
if (isSkipCopilot(options)) {
|
||||
return { ready: true, reason: 'copilot skipped', copilotSkipped: true };
|
||||
if (isSkipCopilot(options) || prior.requiredTimeout) {
|
||||
return {
|
||||
ready: true,
|
||||
reason: prior.requiredTimeout ? prior.reason : 'copilot skipped',
|
||||
copilotSkipped: true,
|
||||
requiredTimeout: prior.requiredTimeout,
|
||||
};
|
||||
}
|
||||
const copilot = copilotReviewReady(comments, reviews);
|
||||
if (copilot.ready) {
|
||||
@@ -283,6 +301,7 @@ module.exports = {
|
||||
isSkipCopilot,
|
||||
REQUIRED_PRIOR,
|
||||
OPTIONAL_PRIOR,
|
||||
REQUIRED_PRIOR_WAIT_MS,
|
||||
COPILOT_REVIEW_BODY,
|
||||
priorReviewerStatus,
|
||||
priorReviewersReady,
|
||||
|
||||
@@ -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. SDK/product value: review whether the change genuinely adds value — stronger PraisonAIUI UX and correct layering (UI/frontend here; core agent logic belongs in praisonaiagents). Reject scope creep; request changes or recommend closing if value is unclear\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items"
|
||||
|
||||
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 = { allowCopilotTimeout: 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 }
|
||||
: { allowCopilotTimeout: 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.10
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Bot PR Recovery
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/15 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'Specific PR number to kick (optional)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
recover:
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.10
|
||||
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.10
|
||||
pr_number: ${{ github.event.inputs.pr_number || '' }}
|
||||
@@ -5,6 +5,7 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
python:
|
||||
@@ -35,6 +36,30 @@ jobs:
|
||||
- name: Integration smoke tests
|
||||
run: pytest tests/integration/test_agentic_roundtrip.py tests/test_feature_sdk_backends.py -q --no-cov --timeout=120
|
||||
|
||||
frontend:
|
||||
name: frontend
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: src/frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
working-directory: src/frontend
|
||||
|
||||
# `npm run build` is the dashboard ship path: dist/ is synced to
|
||||
# src/praisonaiui/templates/frontend/ (see src/frontend/README.md).
|
||||
- name: Build frontend
|
||||
run: npm run build
|
||||
working-directory: src/frontend
|
||||
|
||||
docs:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
@@ -65,3 +90,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.10
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
@@ -67,472 +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 PraisonAI 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 SDK value gate: change strengthens praisonaiagents (no scope creep).
|
||||
4. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
|
||||
5. If agent.py adds >100 lines or new Agent __init__ params, respond MERGE_GATE_VERDICT: BLOCK (manual review required).
|
||||
6. 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 SDK changes without tests. Label `merge-gate-ci-only` exempts CI-only changes under `.github/workflows/`, `.github/actions/`, or merge-gate scripts.
|
||||
7. Confirm no CHANGES_REQUESTED reviews and CI green on HEAD ${{ matrix.head_sha }}.
|
||||
8. 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.10
|
||||
pr_number: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }}
|
||||
merge_method: ${{ github.event.inputs.merge_method || 'merge' }}
|
||||
@@ -1,543 +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.
|
||||
**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:
|
||||
• Cold import of `praisonaiui` must stay under 200 ms; module count must not grow.
|
||||
• Heavy / optional deps (`mcp`, `langchain`, `llama_index`, `semantic_kernel`,
|
||||
`slack_sdk`, `discord.py`, `botbuilder.core`, `openai`, `anthropic`, `mistralai`,
|
||||
`google.generativeai`) must be **lazy-imported** — never eager.
|
||||
• Public API is verb-first. New classes in `src/praisonaiui/message.py` ending
|
||||
in `Message` with a verb prefix (Ask*, Do*, Request*, …) are forbidden —
|
||||
use a module-level `async def` verb helper instead (see `prompt`, `error`).
|
||||
• Deprecations must emit a runtime `warnings.warn(..., DeprecationWarning, stacklevel=2)`
|
||||
on first access, not only in the docstring.
|
||||
• Deterministic serialisation in anything reaching the wire (sorted keys, stable field
|
||||
order). See existing `to_dict` patterns.
|
||||
• Multi-agent + async safe: no global mutable state without a lock.
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
STEP 2 — ARCHITECTURE VALIDATION & ROUTING (MANDATORY before any code)
|
||||
═══════════════════════════════════════════════════════════════
|
||||
Answer these questions inline in your response, then proceed only if you pass:
|
||||
|
||||
ROUTING:
|
||||
1. **Core aiui** (`src/praisonaiui/`) — protocols, message types, server, features.
|
||||
2. **Features** (`src/praisonaiui/features/`) — opt-in capabilities (MCP, platforms, lifecycle, audio, auth, sharing, etc.).
|
||||
3. **UI builders** (`src/praisonaiui/ui.py`) — declarative component helpers.
|
||||
4. **Frontend** (`src/frontend/`) — React components / stores / SSE handlers.
|
||||
5. **Tests** (`tests/unit/`, `tests/integration/`) — new features require both smoke and real-agentic tests.
|
||||
6. **Docs** (`docs/`) — canonical name per feature, migration table for renames.
|
||||
|
||||
EXTERNAL REPOS (do NOT edit from within this repo — route the work to the right place and stop):
|
||||
• Core SDK: `MervinPraison/PraisonAI` (`praisonaiagents`, `praisonai`) — agent runtime.
|
||||
• Tools: `MervinPraison/PraisonAI-Tools` — tool implementations.
|
||||
• Docs site: `MervinPraison/PraisonAIDocs` — product docs.
|
||||
If the requested change belongs in an external repo, comment on the issue explaining
|
||||
where it should live and DO NOT open a PR in this repo.
|
||||
|
||||
CONCEPTUAL CHECKS (if ANY fails — add a comment, do NOT open a PR):
|
||||
• Does it duplicate an existing public symbol in `src/praisonaiui/__init__.py`?
|
||||
• Does it add new class-first message types (`class Ask*Message`, `class *Message`
|
||||
with a verb in the name)? → Use a verb-first helper instead.
|
||||
• Does it add a new hard dependency to `pyproject.toml` `[project] dependencies`?
|
||||
→ Move to `[project.optional-dependencies]` and wrap imports lazily.
|
||||
• Does it break backward compat (rename, signature change, removal)? → Add
|
||||
a deprecation alias that emits `DeprecationWarning`; never hard-remove before the
|
||||
documented removal version (e.g. `0.5.0`).
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
STEP 3 — IMPLEMENT
|
||||
═══════════════════════════════════════════════════════════════
|
||||
Single branch for the whole flow (reuse the same name in STEP 5):
|
||||
BRANCH="claude/issue-${{ env.ISSUE_NUMBER_RESOLVED }}-$(date +%Y%m%d-%H%M%S)"
|
||||
git checkout -b "$BRANCH"
|
||||
|
||||
Make minimal, focused changes:
|
||||
• Stay within the files listed in the issue's "Scope" / "Files to change" section.
|
||||
• Prefer extending existing modules over adding new ones.
|
||||
• No emojis, no comments, no markdown except where the user asked for it.
|
||||
• Python ≥ 3.10 style (`list[...]` not `List[...]`, `X | None` not `Optional[X]`).
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
STEP 4 — TEST / VERIFY
|
||||
═══════════════════════════════════════════════════════════════
|
||||
Run these targeted checks (NOT the full suite):
|
||||
|
||||
# 1. Lint on only the files you changed:
|
||||
CHANGED=$(git diff --name-only origin/main...HEAD | grep -E '\.py$' | tr '\n' ' ')
|
||||
[ -n "$CHANGED" ] && ruff check $CHANGED
|
||||
|
||||
# 2. Unit tests for the module(s) you touched:
|
||||
pytest tests/unit/<your_new_test_file>.py -v --tb=short
|
||||
|
||||
# 3. Import-time invariant (MUST stay under 200 ms cold):
|
||||
python -c "import time, sys; t=time.time(); import praisonaiui; dt=(time.time()-t)*1000; heavy=[m for m in sys.modules if any(h in m for h in ['langchain','llama_index','mcp','slack','discord','botbuilder','openai.','anthropic.','mistralai','google.generativeai'])]; print(f'{dt:.1f}ms modules={len(sys.modules)} leaked={heavy}'); assert heavy == [], f'Heavy deps leaked: {heavy}'"
|
||||
|
||||
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.10
|
||||
@@ -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]
|
||||
@@ -12,11 +10,10 @@ on:
|
||||
- cron: '0 */6 * * *'
|
||||
|
||||
concurrency:
|
||||
group: merge-conflict-scan-${{ github.repository }}
|
||||
group: merge-conflict-scan-${{ github.repository }}-${{ github.event.pull_request.number || 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:
|
||||
@@ -27,157 +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.10
|
||||
- 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.'
|
||||
].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 hasFinalClaudeReviewTrigger(comments) {
|
||||
return comments.some((c) => {
|
||||
const body = (c.body || '').toLowerCase();
|
||||
if (!AUTO_ACTORS.includes(c.user.login)) return false;
|
||||
if (!body.includes('@claude')) return false;
|
||||
if (body.includes('merge conflict')) return false;
|
||||
return body.includes('final architecture reviewer') || body.includes('lead engineer');
|
||||
});
|
||||
}
|
||||
|
||||
function 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' };
|
||||
}
|
||||
|
||||
if (!hasFinalClaudeReviewTrigger(comments)) {
|
||||
return { skipped: true, reason: 'awaiting final claude review trigger' };
|
||||
}
|
||||
|
||||
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.10
|
||||
@@ -44,6 +44,7 @@ Thumbs.db
|
||||
# Project specific
|
||||
aiui/
|
||||
*.log
|
||||
*.err
|
||||
.praison/
|
||||
.gemini/
|
||||
.internal/
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Security Policy
|
||||
|
||||
We take the security of PraisonAIUI seriously and appreciate responsible
|
||||
disclosure of vulnerabilities.
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Security fixes are applied to the latest released version of PraisonAIUI.
|
||||
Please make sure you are on the most recent release before reporting.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Please do not open a public issue for security vulnerabilities.**
|
||||
|
||||
Report privately through GitHub's **Private Vulnerability Reporting**:
|
||||
|
||||
1. Go to the [Security tab](https://github.com/MervinPraison/PraisonAIUI/security)
|
||||
of this repository.
|
||||
2. Click **"Report a vulnerability"** to open a private advisory.
|
||||
3. Include as much detail as you can:
|
||||
- Affected component(s) and version.
|
||||
- Steps to reproduce.
|
||||
- Impact assessment.
|
||||
- Any suggested fix or mitigation.
|
||||
|
||||
If you are unable to use private reporting, you may open a public issue that
|
||||
contains **no technical specifics** and requests a private channel, and a
|
||||
maintainer will follow up.
|
||||
|
||||
## What to Expect
|
||||
|
||||
- We aim to acknowledge new reports within a few business days.
|
||||
- We will keep you informed as we investigate and work on a fix.
|
||||
- Once a fix is released, we are happy to credit you in the advisory (unless
|
||||
you prefer to remain anonymous).
|
||||
|
||||
Thank you for helping keep PraisonAIUI and its users safe.
|
||||
+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:
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Knowledge Embedding Model Configuration
|
||||
|
||||
Knowledge / RAG search uses vector **embeddings**. The default embedding model is
|
||||
`text-embedding-3-small`. Many OpenAI keys or projects do not have access to that
|
||||
model (organization tier, model allowlist, or regional restrictions), which caused
|
||||
knowledge search to silently degrade to local substring matching.
|
||||
|
||||
PraisonAIUI now makes this failure mode configurable and observable.
|
||||
|
||||
## Configure the embedding model
|
||||
|
||||
Set an accessible model via environment variable:
|
||||
|
||||
```bash
|
||||
export AIUI_EMBEDDING_MODEL=text-embedding-ada-002
|
||||
```
|
||||
|
||||
`OPENAI_EMBEDDING_MODEL` is also honoured as a fallback if `AIUI_EMBEDDING_MODEL`
|
||||
is not set.
|
||||
|
||||
## Fallback chain
|
||||
|
||||
The OpenAI-compatible `/v1/embeddings` endpoint tries the requested (or default)
|
||||
model first, then each model in the fallback chain if the primary returns a
|
||||
`model_not_found` / 403 access error:
|
||||
|
||||
```bash
|
||||
export AIUI_EMBEDDING_FALLBACK_MODELS="text-embedding-ada-002,text-embedding-3-small"
|
||||
```
|
||||
|
||||
If every candidate is inaccessible, the endpoint returns `503` with an actionable
|
||||
`model_not_found` error instead of a generic `500`.
|
||||
|
||||
## Observing degraded search
|
||||
|
||||
`GET /api/knowledge/status` reports embedding availability:
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 12,
|
||||
"backend": "SDKKnowledgeManager",
|
||||
"status": "degraded",
|
||||
"embedding": {"available": false, "error": "Project does not have access to model `text-embedding-3-small`"},
|
||||
"warnings": ["Embedding model unavailable; knowledge search degraded to local text matching. Set AIUI_EMBEDDING_MODEL to an accessible model."]
|
||||
}
|
||||
```
|
||||
|
||||
`POST /api/knowledge/search` indicates whether results came from vector search or a
|
||||
degraded local fallback:
|
||||
|
||||
```json
|
||||
{
|
||||
"results": [],
|
||||
"count": 0,
|
||||
"search_mode": "fallback",
|
||||
"warning": "Vector search unavailable: Project does not have access to model text-embedding-3-small. Set AIUI_EMBEDDING_MODEL=text-embedding-ada-002 or enable embedding models in your provider project."
|
||||
}
|
||||
```
|
||||
|
||||
A dashboard can show a warning banner whenever `search_mode == "fallback"` or
|
||||
`embedding.available == false`.
|
||||
|
||||
## Model reference
|
||||
|
||||
| Model | Notes |
|
||||
|-------|-------|
|
||||
| `text-embedding-3-small` | Default — best cost/quality when available |
|
||||
| `text-embedding-3-large` | Higher quality — may also be restricted |
|
||||
| `text-embedding-ada-002` | Legacy — widely available on older projects |
|
||||
+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,69 @@
|
||||
# Application Design Protocol (ADP)
|
||||
|
||||
Declarative UI design standard for describing application layout, navigation, components, and theming from YAML or JSON manifests.
|
||||
|
||||
**Status:** v1alpha1 research draft — see [implementation-status.md](./implementation-status.md) for what is implemented today.
|
||||
|
||||
## Documents
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [Research report](../application-design-protocol-report.md) | 46 standards compared; YAML vs JSON debate; design principles |
|
||||
| [spec.md](./spec.md) | Normative specification (draft) |
|
||||
| [validation-rules.md](./validation-rules.md) | Cross-reference rules R1–R30 |
|
||||
| [aiui-mapping.md](./aiui-mapping.md) | PraisonAIUI field mapping |
|
||||
| [implementation-status.md](./implementation-status.md) | Feature implementation tracker |
|
||||
|
||||
## Schemas
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| [schema/application.schema.json](./schema/application.schema.json) | Root Application kind |
|
||||
| [schema/component-catalog.schema.json](./schema/component-catalog.schema.json) | Catalog meta-schema |
|
||||
| [schema/theme.schema.json](./schema/theme.schema.json) | Theme preset or DTCG tokens |
|
||||
| [catalogs/default.catalog.json](./catalogs/default.catalog.json) | Default component and layout catalog |
|
||||
|
||||
## Examples
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| [examples/minimal.application.yaml](./examples/minimal.application.yaml) | Smallest valid app |
|
||||
| [examples/docs-site.application.yaml](./examples/docs-site.application.yaml) | Full docs site |
|
||||
| [examples/landing-page.application.yaml](./examples/landing-page.application.yaml) | Zone-based layout |
|
||||
|
||||
## Validate locally
|
||||
|
||||
```bash
|
||||
pip install jsonschema pyyaml
|
||||
pytest tests/unit/test_adp_schemas.py -v
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```yaml
|
||||
schemaVersion: 1
|
||||
apiVersion: adp.io/v1alpha1
|
||||
kind: Application
|
||||
|
||||
metadata:
|
||||
name: my-app
|
||||
version: "1.0.0"
|
||||
|
||||
spec:
|
||||
site:
|
||||
title: "My App"
|
||||
theme:
|
||||
preset: zinc
|
||||
darkMode: true
|
||||
templates:
|
||||
docs:
|
||||
layout: TwoColumnLayout
|
||||
slots:
|
||||
main: { type: DocContent }
|
||||
routes:
|
||||
- match: "/docs/**"
|
||||
template: docs
|
||||
content:
|
||||
docs:
|
||||
dir: "./docs"
|
||||
```
|
||||
@@ -0,0 +1,138 @@
|
||||
# PraisonAIUI → ADP Field Mapping
|
||||
|
||||
Field-by-field mapping from current `aiui.template.yaml` (`Config` model in `src/praisonaiui/schema/models.py`) to ADP `Application` spec.
|
||||
|
||||
**Compatibility:** Flat `aiui.template.yaml` files work with the PraisonAIUI compiler today. ADP envelope format is **planned** — see [implementation-status.md](./implementation-status.md).
|
||||
|
||||
---
|
||||
|
||||
## Envelope (new in ADP)
|
||||
|
||||
| ADP field | PraisonAIUI today | Status | Change |
|
||||
|-----------|-------------------|--------|--------|
|
||||
| `schemaVersion: 1` | `schemaVersion: 1` | Implemented | Compatible |
|
||||
| `apiVersion: adp.io/v1alpha1` | Not present | Planned | Add when envelope parser lands |
|
||||
| `kind: Application` | Not present | Planned | Add when envelope parser lands |
|
||||
| `metadata.name` | Not present | Planned | Derive from `site.title` slug |
|
||||
| `metadata.version` | Not present | Planned | Recommend SemVer |
|
||||
|
||||
## Top-level → spec wrapper
|
||||
|
||||
| PraisonAIUI (flat) | ADP (envelope) | Status | Change |
|
||||
|--------------------|----------------|--------|--------|
|
||||
| `site:` | `spec.site:` | Planned | Wrap under `spec` |
|
||||
| `site.theme` | `spec.theme:` | Planned | Promote theme to spec level |
|
||||
| `navigation:` | `spec.navigation:` | Partial | Tabs compiled; not rendered |
|
||||
| `navbar:` | `spec.navbar:` | Partial | Compiled; not rendered |
|
||||
| `footer:` | `spec.footer:` | Partial | Compiled; not rendered |
|
||||
| `search:` | `spec.search:` | Partial | Compiled; not rendered |
|
||||
| `content:` | `spec.content:` | Implemented | Same |
|
||||
| `components:` | `spec.components:` | Implemented | Same |
|
||||
| `templates:` | `spec.templates:` | Implemented | Same |
|
||||
| `routes:` | `spec.routes:` | Partial | Compiled; routing not honoured |
|
||||
| `dependencies:` | `spec.dependencies:` | Implemented | Same |
|
||||
| `logo:` (top-level) | Future ADP field | Implemented | Top-level on `Config`, not under `site` |
|
||||
| `style:`, `chat:`, `auth:`, `dashboard:` | Not in ADP v1alpha1 | N/A | Separate kinds in future |
|
||||
|
||||
## Navigation fields
|
||||
|
||||
| Field | PraisonAIUI | ADP | Status | Notes |
|
||||
|-------|-------------|-----|--------|-------|
|
||||
| `navigation.tabs[]` | Mintlify-style tabs | `spec.navigation.tabs` | Partial | Compiled, not rendered |
|
||||
| `content.docs.nav.mode` | `NavConfig.mode` | Same | Gap | Defined but unused by compiler |
|
||||
| Top-level `navigation.mode` | Not present | Removed from ADP | N/A | Use `content.docs.nav.mode` instead |
|
||||
|
||||
## Theme fields
|
||||
|
||||
| Field | PraisonAIUI | ADP | Status |
|
||||
|-------|-------------|-----|--------|
|
||||
| `site.theme.preset` | Implemented | `spec.theme.preset` | Planned (envelope) |
|
||||
| `site.theme.radius` | Implemented | Same | Same |
|
||||
| `site.theme.darkMode` | Implemented | Same | Same |
|
||||
| `theme.tokens` | Not present | DTCG file ref | Planned |
|
||||
|
||||
## Component fields
|
||||
|
||||
| Field | PraisonAIUI | ADP | Status |
|
||||
|-------|-------------|-----|--------|
|
||||
| `components.{id}.type` | String literal | Catalog allowlist | Planned (R6) |
|
||||
| `components.{id}.props` | `dict[str, Any]` | Catalog schema | Planned (R7) |
|
||||
|
||||
## Template fields
|
||||
|
||||
| Field | PraisonAIUI | ADP | Status |
|
||||
|-------|-------------|-----|--------|
|
||||
| `templates.{id}.layout` | String | Catalog enum | Same values |
|
||||
| `templates.{id}.slots` | `{ ref \| type }` | Same | Same |
|
||||
| `templates.{id}.zones` | Widget arrays | Same | Same |
|
||||
| Slots + zones on same template | Allowed | R13: one or the other | Stricter in ADP |
|
||||
|
||||
## Route fields
|
||||
|
||||
| Field | PraisonAIUI | ADP | Status |
|
||||
|-------|-------------|-----|--------|
|
||||
| `routes[].match` | Glob string | Same | Same |
|
||||
| `routes[].template` | Template key | Same | Same |
|
||||
| Runtime route resolution | Not implemented | R28 required | Gap |
|
||||
|
||||
## Migration example
|
||||
|
||||
### Today (PraisonAIUI)
|
||||
|
||||
```yaml
|
||||
schemaVersion: 1
|
||||
|
||||
site:
|
||||
title: "My Docs"
|
||||
theme:
|
||||
preset: zinc
|
||||
|
||||
templates:
|
||||
docs:
|
||||
layout: TwoColumnLayout
|
||||
slots:
|
||||
main: { type: DocContent }
|
||||
|
||||
routes:
|
||||
- match: "/docs/**"
|
||||
template: docs
|
||||
```
|
||||
|
||||
### Target (ADP)
|
||||
|
||||
```yaml
|
||||
schemaVersion: 1
|
||||
apiVersion: adp.io/v1alpha1
|
||||
kind: Application
|
||||
|
||||
metadata:
|
||||
name: my-docs
|
||||
version: "1.0.0"
|
||||
|
||||
spec:
|
||||
site:
|
||||
title: "My Docs"
|
||||
theme:
|
||||
preset: zinc
|
||||
templates:
|
||||
docs:
|
||||
layout: TwoColumnLayout
|
||||
slots:
|
||||
main: { type: DocContent }
|
||||
routes:
|
||||
- match: "/docs/**"
|
||||
template: docs
|
||||
content:
|
||||
docs:
|
||||
dir: "./docs"
|
||||
```
|
||||
|
||||
## Breaking vs compatible changes
|
||||
|
||||
| Change | Breaking? | Mitigation |
|
||||
|--------|-----------|------------|
|
||||
| Add envelope fields | No | Envelope parser normalises to flat `Config` |
|
||||
| Catalog validation | Soft break | Warn first, error in strict mode |
|
||||
| Route runtime resolution | No (fix) | Frontend change |
|
||||
| R13 slots/zones exclusivity | Soft break | Warn on violation |
|
||||
| Remove chat/dashboard from ADP | No | Separate kinds in future |
|
||||
@@ -0,0 +1,128 @@
|
||||
{
|
||||
"name": "adp-default-catalog",
|
||||
"version": "1.0.0",
|
||||
"description": "Default ADP component and layout catalog for PraisonAIUI",
|
||||
"components": {
|
||||
"Header": {
|
||||
"description": "Site header with logo and navigation",
|
||||
"props": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"logoText": { "type": "string" },
|
||||
"logoHref": { "type": "string", "default": "/" }
|
||||
},
|
||||
"required": ["logoText"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"aria": { "role": "banner" },
|
||||
"slots": ["header"]
|
||||
},
|
||||
"Footer": {
|
||||
"description": "Site footer",
|
||||
"props": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"aria": { "role": "contentinfo" },
|
||||
"slots": ["footer"]
|
||||
},
|
||||
"DocsSidebar": {
|
||||
"description": "Documentation sidebar navigation",
|
||||
"props": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source": { "type": "string", "enum": ["docs-nav", "manual"], "default": "docs-nav" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"aria": { "role": "navigation" },
|
||||
"slots": ["left", "leftSidebar"]
|
||||
},
|
||||
"DocContent": {
|
||||
"description": "Main documentation content area",
|
||||
"props": { "type": "object", "additionalProperties": false },
|
||||
"aria": { "role": "main" },
|
||||
"slots": ["main"]
|
||||
},
|
||||
"Toc": {
|
||||
"description": "Table of contents for current page",
|
||||
"props": { "type": "object", "additionalProperties": false },
|
||||
"aria": { "role": "navigation" },
|
||||
"slots": ["right", "rightSidebar"]
|
||||
},
|
||||
"HeroBanner": {
|
||||
"description": "Hero banner with title and CTA",
|
||||
"props": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": { "type": "string" },
|
||||
"subtitle": { "type": "string" },
|
||||
"ctaLabel": { "type": "string" },
|
||||
"ctaHref": { "type": "string" }
|
||||
},
|
||||
"required": ["title"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"slots": ["hero"]
|
||||
},
|
||||
"SocialLinks": {
|
||||
"description": "Social media link row",
|
||||
"props": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"links": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"platform": { "type": "string" },
|
||||
"href": { "type": "string" }
|
||||
},
|
||||
"required": ["platform", "href"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"slots": ["footer"]
|
||||
},
|
||||
"Copyright": {
|
||||
"description": "Copyright notice",
|
||||
"props": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": { "type": "string" }
|
||||
},
|
||||
"required": ["text"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"slots": ["footer"]
|
||||
}
|
||||
},
|
||||
"layouts": {
|
||||
"ThreeColumnLayout": {
|
||||
"description": "Header + left sidebar + main + right TOC + footer",
|
||||
"slots": ["header", "left", "main", "right", "footer"]
|
||||
},
|
||||
"TwoColumnLayout": {
|
||||
"description": "Sidebar + main content",
|
||||
"slots": ["left", "main"]
|
||||
},
|
||||
"CenteredLayout": {
|
||||
"description": "Centred single column",
|
||||
"slots": ["main"]
|
||||
},
|
||||
"FullWidthLayout": {
|
||||
"description": "Full-width content",
|
||||
"slots": ["header", "main", "footer"]
|
||||
},
|
||||
"FlexibleLayout": {
|
||||
"description": "WordPress-style zone grid",
|
||||
"zones": ["header", "topNav", "hero", "leftSidebar", "main", "rightSidebar", "bottomNav", "footer"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
schemaVersion: 1
|
||||
apiVersion: adp.io/v1alpha1
|
||||
kind: Application
|
||||
|
||||
metadata:
|
||||
name: praisonaiui-docs
|
||||
version: "1.0.0"
|
||||
labels:
|
||||
generator: praisonaiui
|
||||
|
||||
spec:
|
||||
site:
|
||||
title: "PraisonAIUI Docs"
|
||||
description: "Documentation for PraisonAIUI - YAML-driven website generator"
|
||||
ui: shadcn
|
||||
|
||||
theme:
|
||||
preset: zinc
|
||||
radius: md
|
||||
darkMode: true
|
||||
|
||||
navigation:
|
||||
tabs:
|
||||
- tab: "Documentation"
|
||||
groups:
|
||||
- group: "Getting Started"
|
||||
icon: rocket
|
||||
prefix: getting-started
|
||||
pages:
|
||||
- index
|
||||
- getting-started/installation
|
||||
- getting-started/quickstart
|
||||
- group: "Core Concepts"
|
||||
icon: lightbulb
|
||||
prefix: concepts
|
||||
pages:
|
||||
- concepts/architecture
|
||||
- concepts/configuration
|
||||
- concepts/templates
|
||||
- tab: "API Reference"
|
||||
groups:
|
||||
- group: "API Reference"
|
||||
icon: code
|
||||
prefix: api
|
||||
pages:
|
||||
- api/cli
|
||||
- api/python
|
||||
|
||||
navbar:
|
||||
primary:
|
||||
type: github
|
||||
href: "https://github.com/MervinPraison/PraisonAIUI"
|
||||
links:
|
||||
- label: "Submit Issues"
|
||||
href: "https://github.com/MervinPraison/PraisonAIUI/issues"
|
||||
|
||||
footer:
|
||||
socials:
|
||||
github: "https://github.com/MervinPraison/PraisonAI"
|
||||
links:
|
||||
- header: "Resources"
|
||||
items:
|
||||
- label: "GitHub"
|
||||
href: "https://github.com/MervinPraison/PraisonAI"
|
||||
|
||||
search:
|
||||
enabled: true
|
||||
provider: fusejs
|
||||
|
||||
content:
|
||||
docs:
|
||||
dir: "./docs"
|
||||
include:
|
||||
- "**/*.md"
|
||||
- "**/*.mdx"
|
||||
nav:
|
||||
mode: manual
|
||||
|
||||
components:
|
||||
header_main:
|
||||
type: Header
|
||||
props:
|
||||
logoText: "PraisonAIUI"
|
||||
footer_main:
|
||||
type: Footer
|
||||
props:
|
||||
text: "© 2026 PraisonAI"
|
||||
sidebar_docs:
|
||||
type: DocsSidebar
|
||||
props:
|
||||
source: docs-nav
|
||||
|
||||
templates:
|
||||
docs:
|
||||
layout: ThreeColumnLayout
|
||||
slots:
|
||||
header: { ref: header_main }
|
||||
left: { ref: sidebar_docs }
|
||||
main: { type: DocContent }
|
||||
footer: { ref: footer_main }
|
||||
|
||||
routes:
|
||||
- match: "/docs/**"
|
||||
template: docs
|
||||
@@ -0,0 +1,58 @@
|
||||
schemaVersion: 1
|
||||
apiVersion: adp.io/v1alpha1
|
||||
kind: Application
|
||||
|
||||
metadata:
|
||||
name: colorful-landing
|
||||
version: "1.0.0"
|
||||
|
||||
spec:
|
||||
site:
|
||||
title: "Colorful Docs"
|
||||
description: "Zone-based landing page with hero and footer widgets"
|
||||
ui: shadcn
|
||||
|
||||
theme:
|
||||
preset: rose
|
||||
radius: xl
|
||||
darkMode: true
|
||||
|
||||
content:
|
||||
docs:
|
||||
dir: "./docs"
|
||||
nav:
|
||||
mode: auto
|
||||
collapsible: true
|
||||
|
||||
templates:
|
||||
docs:
|
||||
layout: FlexibleLayout
|
||||
zones:
|
||||
hero:
|
||||
- type: HeroBanner
|
||||
props:
|
||||
title: "Colorful Documentation"
|
||||
subtitle: "Vibrant, playful, and beautifully designed"
|
||||
ctaLabel: "Get Started"
|
||||
ctaHref: "/docs/getting-started/quickstart"
|
||||
rightSidebar:
|
||||
- type: Toc
|
||||
footer:
|
||||
- type: SocialLinks
|
||||
props:
|
||||
links:
|
||||
- platform: Twitter
|
||||
href: "https://twitter.com"
|
||||
- platform: GitHub
|
||||
href: "https://github.com"
|
||||
- type: Copyright
|
||||
props:
|
||||
text: "Made with love by Colorful Team"
|
||||
|
||||
routes:
|
||||
- match: "/docs/**"
|
||||
template: docs
|
||||
|
||||
a11y:
|
||||
skipToContent: true
|
||||
reduceMotion: false
|
||||
@@ -0,0 +1,32 @@
|
||||
schemaVersion: 1
|
||||
apiVersion: adp.io/v1alpha1
|
||||
kind: Application
|
||||
|
||||
metadata:
|
||||
name: minimal-docs
|
||||
version: "1.0.0"
|
||||
|
||||
spec:
|
||||
site:
|
||||
title: "Minimal Docs"
|
||||
description: "Smallest valid ADP Application manifest"
|
||||
ui: shadcn
|
||||
|
||||
theme:
|
||||
preset: zinc
|
||||
radius: md
|
||||
darkMode: true
|
||||
|
||||
content:
|
||||
docs:
|
||||
dir: "./docs"
|
||||
|
||||
templates:
|
||||
docs:
|
||||
layout: TwoColumnLayout
|
||||
slots:
|
||||
main: { type: DocContent }
|
||||
|
||||
routes:
|
||||
- match: "/docs/**"
|
||||
template: docs
|
||||
@@ -0,0 +1,36 @@
|
||||
# ADP Implementation Status
|
||||
|
||||
Honest status of ADP v1alpha1 features against the PraisonAIUI reference implementation.
|
||||
|
||||
**Last updated:** August 2026
|
||||
|
||||
| Feature | Spec reference | Status | Notes |
|
||||
|---------|----------------|--------|-------|
|
||||
| JSON Schema validation in CLI | spec §6 | Planned | Schemas published; no `aiui validate --adp` yet |
|
||||
| ADP envelope parsing (`apiVersion/kind/spec`) | spec §3 | Planned | Flat `aiui.template.yaml` works today |
|
||||
| Component catalog validation (R6–R7) | spec §5 | Planned | Catalog split to `catalogs/default.catalog.json` |
|
||||
| `$yaml:` / `$json:` file includes | spec §7 | Planned | Not implemented in compiler |
|
||||
| Strict mode unknown-field rejection (R4) | validation-rules R4 | Planned | Pydantic allows extra fields on some models |
|
||||
| Route-driven template resolution (R28) | validation-rules R28 | Gap | Frontend hardcodes `templates.docs` |
|
||||
| Sidebar nav mode (R29) | validation-rules R29 | Gap | Filesystem-only sidebar today |
|
||||
| Catalog ARIA roles at render (R30) | validation-rules R30 | Gap | No catalog-driven a11y in renderer |
|
||||
| `navigation.tabs` rendering | spec §4.3 | Gap | Compiled to manifest but not consumed by React |
|
||||
| DTCG token file validation (R26) | validation-rules R26 | Planned | Preset shorthand works via Tailwind |
|
||||
| Published `$schema` URLs | README | Partial | Local schemas only; `https://adp.io/` is placeholder |
|
||||
|
||||
## What works today
|
||||
|
||||
- Flat `aiui.template.yaml` → Pydantic `Config` → compiler → JSON manifests → React SPA
|
||||
- Components, templates, routes, theme presets, content scanning
|
||||
- ADP example manifests validate against `application.schema.json` (standalone)
|
||||
- Unit tests in `tests/unit/test_adp_schemas.py`
|
||||
|
||||
## Migration path
|
||||
|
||||
1. Add envelope normalisation layer before `Config.model_validate`
|
||||
2. Wire `route-manifest.json` into frontend routing (R28)
|
||||
3. Honour `navigation.tabs` and `content.docs.nav.mode` (R29)
|
||||
4. Add `aiui validate --schema adp` using published JSON Schema
|
||||
5. Apply catalog ARIA hints at render time (R30)
|
||||
|
||||
See [aiui-mapping.md](./aiui-mapping.md) for field-level compatibility.
|
||||
@@ -0,0 +1,449 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "application.schema.json",
|
||||
"title": "ADP Application",
|
||||
"description": "Application Design Protocol — Application kind manifest",
|
||||
"type": "object",
|
||||
"required": ["schemaVersion", "apiVersion", "kind", "metadata", "spec"],
|
||||
"additionalProperties": false,
|
||||
"patternProperties": {
|
||||
"^x-adp-": {}
|
||||
},
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"type": "integer",
|
||||
"const": 1,
|
||||
"description": "ADP envelope version"
|
||||
},
|
||||
"apiVersion": {
|
||||
"type": "string",
|
||||
"const": "adp.io/v1alpha1"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"const": "Application"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"required": ["name"],
|
||||
"additionalProperties": false,
|
||||
"patternProperties": {
|
||||
"^x-adp-": {}
|
||||
},
|
||||
"properties": {
|
||||
"name": { "type": "string", "minLength": 1 },
|
||||
"version": { "type": "string" },
|
||||
"labels": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" }
|
||||
},
|
||||
"annotations": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"type": "object",
|
||||
"required": ["site", "templates", "routes"],
|
||||
"additionalProperties": false,
|
||||
"patternProperties": {
|
||||
"^x-adp-": {}
|
||||
},
|
||||
"properties": {
|
||||
"site": { "$ref": "#/$defs/site" },
|
||||
"theme": { "$ref": "#/$defs/theme" },
|
||||
"navigation": { "$ref": "#/$defs/navigation" },
|
||||
"navbar": { "$ref": "#/$defs/navbar" },
|
||||
"footer": { "$ref": "#/$defs/footer" },
|
||||
"search": { "$ref": "#/$defs/search" },
|
||||
"components": { "$ref": "#/$defs/components" },
|
||||
"templates": { "$ref": "#/$defs/templates" },
|
||||
"routes": { "$ref": "#/$defs/routes" },
|
||||
"content": { "$ref": "#/$defs/content" },
|
||||
"dependencies": { "$ref": "#/$defs/dependencies" },
|
||||
"seo": { "$ref": "#/$defs/seo" },
|
||||
"i18n": { "$ref": "#/$defs/i18n" },
|
||||
"a11y": { "$ref": "#/$defs/a11y" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"site": {
|
||||
"type": "object",
|
||||
"required": ["title"],
|
||||
"additionalProperties": false,
|
||||
"patternProperties": {
|
||||
"^x-adp-": {}
|
||||
},
|
||||
"properties": {
|
||||
"title": { "type": "string" },
|
||||
"description": { "type": "string" },
|
||||
"ui": {
|
||||
"type": "string",
|
||||
"enum": ["shadcn", "mui", "chakra"],
|
||||
"default": "shadcn"
|
||||
},
|
||||
"routeBaseDocs": { "type": "string", "default": "/docs" },
|
||||
"customCss": { "type": "string" },
|
||||
"plugins": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"oneOf": [
|
||||
{
|
||||
"title": "Preset theme",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"preset": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"zinc", "slate", "stone", "gray", "neutral",
|
||||
"red", "orange", "amber", "yellow", "lime", "green",
|
||||
"emerald", "teal", "cyan", "sky", "blue", "indigo",
|
||||
"violet", "purple", "fuchsia", "pink", "rose"
|
||||
]
|
||||
},
|
||||
"radius": {
|
||||
"type": "string",
|
||||
"enum": ["none", "sm", "md", "lg", "xl"]
|
||||
},
|
||||
"darkMode": { "type": "boolean" }
|
||||
},
|
||||
"required": ["preset"]
|
||||
},
|
||||
{
|
||||
"title": "DTCG token reference",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"tokens": {
|
||||
"type": "string",
|
||||
"description": "Path or URL to DTCG 2025.10 compliant tokens.json"
|
||||
},
|
||||
"darkMode": { "type": "boolean" }
|
||||
},
|
||||
"required": ["tokens"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"slotRef": {
|
||||
"oneOf": [
|
||||
{ "type": "null" },
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["ref"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"ref": { "type": "string" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["type"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": { "type": "string" },
|
||||
"props": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"widget": {
|
||||
"type": "object",
|
||||
"required": ["type"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": { "type": "string" },
|
||||
"props": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"required": ["type"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": { "type": "string" },
|
||||
"props": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"template": {
|
||||
"type": "object",
|
||||
"required": ["layout"],
|
||||
"allOf": [
|
||||
{
|
||||
"oneOf": [
|
||||
{ "required": ["slots"] },
|
||||
{ "required": ["zones"] }
|
||||
]
|
||||
}
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"layout": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ThreeColumnLayout",
|
||||
"TwoColumnLayout",
|
||||
"CenteredLayout",
|
||||
"FullWidthLayout",
|
||||
"FlexibleLayout"
|
||||
]
|
||||
},
|
||||
"slots": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "$ref": "#/$defs/slotRef" }
|
||||
},
|
||||
"zones": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/widget" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"templates": {
|
||||
"type": "object",
|
||||
"minProperties": 1,
|
||||
"additionalProperties": { "$ref": "#/$defs/template" }
|
||||
},
|
||||
"route": {
|
||||
"type": "object",
|
||||
"required": ["match", "template"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"match": { "type": "string" },
|
||||
"template": { "type": "string" },
|
||||
"slots": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "$ref": "#/$defs/slotRef" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"routes": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/$defs/route" }
|
||||
},
|
||||
"navGroup": {
|
||||
"type": "object",
|
||||
"required": ["group"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"group": { "type": "string" },
|
||||
"icon": { "type": "string" },
|
||||
"prefix": { "type": "string" },
|
||||
"pages": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"navTab": {
|
||||
"type": "object",
|
||||
"required": ["tab"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"tab": { "type": "string" },
|
||||
"url": { "type": "string" },
|
||||
"groups": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/navGroup" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"navigation": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"tabs": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/navTab" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"primary": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": { "type": "string", "enum": ["button", "github"] },
|
||||
"label": { "type": "string" },
|
||||
"href": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["label", "href"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"label": { "type": "string" },
|
||||
"href": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"socials": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" }
|
||||
},
|
||||
"links": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["header"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"header": { "type": "string" },
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["label", "href"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"label": { "type": "string" },
|
||||
"href": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"enum": ["fusejs", "lunrjs", "pagefind"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"shadcn": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"contentSource": {
|
||||
"type": "object",
|
||||
"required": ["dir"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"dir": { "type": "string" },
|
||||
"include": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"exclude": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"indexFiles": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"nav": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"mode": { "type": "string", "enum": ["auto", "manual"] },
|
||||
"sort": { "type": "string", "enum": ["filesystem", "alpha", "date"] },
|
||||
"collapsible": { "type": "boolean" },
|
||||
"maxDepth": { "type": "integer", "minimum": 1, "maximum": 10 }
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": { "mode": { "const": "manual" } },
|
||||
"required": ["mode"]
|
||||
},
|
||||
"then": {
|
||||
"description": "Manual sidebar mode requires navigation.tabs at spec level"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"docs": { "$ref": "#/$defs/contentSource" },
|
||||
"blog": { "$ref": "#/$defs/contentSource" }
|
||||
}
|
||||
},
|
||||
"seo": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"titleTemplate": { "type": "string" },
|
||||
"defaultImage": { "type": "string" },
|
||||
"twitter": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"defaultLocale": { "type": "string" },
|
||||
"locales": { "type": "array", "items": { "type": "string" } },
|
||||
"rtlLocales": { "type": "array", "items": { "type": "string" } },
|
||||
"fallbackLocale": { "type": "string" },
|
||||
"translationsDir": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"a11y": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"skipToContent": { "type": "boolean" },
|
||||
"focusVisible": { "type": "boolean" },
|
||||
"reduceMotion": { "type": "boolean" },
|
||||
"ariaLabels": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "component-catalog.schema.json",
|
||||
"title": "ADP Component Catalog",
|
||||
"description": "Allowlisted component types for ADP Application manifests",
|
||||
"type": "object",
|
||||
"required": ["name", "version", "components"],
|
||||
"additionalProperties": false,
|
||||
"patternProperties": {
|
||||
"^x-adp-": {}
|
||||
},
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"version": { "type": "string" },
|
||||
"description": { "type": "string" },
|
||||
"components": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "$ref": "#/$defs/componentType" }
|
||||
},
|
||||
"layouts": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "$ref": "#/$defs/layoutType" }
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"componentType": {
|
||||
"type": "object",
|
||||
"required": ["description", "props"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"description": { "type": "string" },
|
||||
"props": { "type": "object" },
|
||||
"aria": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"role": { "type": "string" },
|
||||
"label": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"slots": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"deprecated": { "type": "boolean" },
|
||||
"x-sunset": { "type": "string", "format": "date" }
|
||||
}
|
||||
},
|
||||
"layoutType": {
|
||||
"oneOf": [
|
||||
{
|
||||
"title": "Slot-based layout",
|
||||
"type": "object",
|
||||
"required": ["description", "slots"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"description": { "type": "string" },
|
||||
"slots": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"minItems": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Zone-based layout",
|
||||
"type": "object",
|
||||
"required": ["description", "zones"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"description": { "type": "string" },
|
||||
"zones": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"minItems": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "theme.schema.json",
|
||||
"title": "ADP Theme",
|
||||
"description": "Theme configuration — preset shorthand or DTCG 2025.10 token reference",
|
||||
"oneOf": [
|
||||
{
|
||||
"title": "Preset theme",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"preset": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"zinc", "slate", "stone", "gray", "neutral",
|
||||
"red", "orange", "amber", "yellow", "lime", "green",
|
||||
"emerald", "teal", "cyan", "sky", "blue", "indigo",
|
||||
"violet", "purple", "fuchsia", "pink", "rose"
|
||||
]
|
||||
},
|
||||
"radius": {
|
||||
"type": "string",
|
||||
"enum": ["none", "sm", "md", "lg", "xl"],
|
||||
"default": "md"
|
||||
},
|
||||
"darkMode": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"required": ["preset"]
|
||||
},
|
||||
{
|
||||
"title": "DTCG token reference",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"tokens": {
|
||||
"type": "string",
|
||||
"description": "Path or URL to DTCG 2025.10 compliant tokens.json"
|
||||
},
|
||||
"darkMode": { "type": "boolean" }
|
||||
},
|
||||
"required": ["tokens"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
# Application Design Protocol (ADP) — Normative Specification
|
||||
|
||||
**Version:** v1alpha1 (draft)
|
||||
**Status:** Draft — not yet normative
|
||||
**Date:** August 2026
|
||||
|
||||
This document uses [RFC 2119](https://datatracker.ietf.org/doc/html/rfc2119) keywords: MUST, MUST NOT, SHOULD, MAY.
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope
|
||||
|
||||
ADP defines a declarative format for describing **application UI design**: site metadata, theming, navigation, reusable components, page templates, and URL routing. It does NOT define:
|
||||
|
||||
- Server-side APIs or data models
|
||||
- Agent orchestration or workflow graphs
|
||||
- Infrastructure or deployment configuration
|
||||
- Executable code or templating logic in manifests
|
||||
|
||||
Implementations MUST treat ADP manifests as **data**, not code.
|
||||
|
||||
---
|
||||
|
||||
## 2. Terminology
|
||||
|
||||
| Term | Definition |
|
||||
|------|------------|
|
||||
| **Manifest** | A YAML or JSON document conforming to this spec |
|
||||
| **Application** | The root `kind: Application` manifest describing a complete UI |
|
||||
| **Component** | A named, reusable UI block with a `type` and `props` |
|
||||
| **Template** | A layout definition assigning components to slots or zones |
|
||||
| **Slot** | A named region within a layout (e.g. `header`, `main`) |
|
||||
| **Zone** | An ordered list of widgets in a slot (WordPress-style) |
|
||||
| **Route** | A URL pattern mapping to a template |
|
||||
| **Catalog** | Published JSON Schema defining allowed component types |
|
||||
| **Compiler** | Tool that validates and emits runtime JSON manifests |
|
||||
| **Renderer** | Runtime that consumes compiled manifests and renders UI |
|
||||
|
||||
---
|
||||
|
||||
## 3. Document envelope
|
||||
|
||||
Every ADP manifest MUST include:
|
||||
|
||||
```yaml
|
||||
schemaVersion: 1 # Integer; ADP envelope version
|
||||
apiVersion: adp.io/v1alpha1
|
||||
kind: Application # Application | Theme | ComponentCatalog (future)
|
||||
|
||||
metadata:
|
||||
name: string # Required; unique identifier
|
||||
version: string # SHOULD use SemVer
|
||||
labels: object # MAY contain key-value pairs
|
||||
annotations: object # MAY contain tooling metadata
|
||||
|
||||
spec: object # Required; kind-specific payload
|
||||
```
|
||||
|
||||
### 3.1 Versioning policy
|
||||
|
||||
- `schemaVersion` — integer bumped on breaking envelope changes
|
||||
- `apiVersion` — string group/version (e.g. `adp.io/v1alpha1`, `adp.io/v1`)
|
||||
- `metadata.version` — application SemVer (independent of protocol version)
|
||||
- Breaking changes MUST increment `apiVersion` (e.g. v1alpha1 → v1)
|
||||
- Deprecated fields MUST include `deprecated: true` and SHOULD include `x-sunset` date
|
||||
|
||||
### 3.2 Authoring formats
|
||||
|
||||
Manifests MAY be authored as YAML (`.application.yaml`) or JSON (`.application.json`). Semantics MUST be identical. Runtimes MUST NOT consume YAML directly; compilers MUST normalise to JSON before validation.
|
||||
|
||||
---
|
||||
|
||||
## 4. Application spec
|
||||
|
||||
An `Application` spec MUST contain at minimum:
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `site.title` | YES | Application display name |
|
||||
| `templates` | YES | At least one template |
|
||||
| `routes` | YES | At least one route |
|
||||
|
||||
All other fields are OPTIONAL.
|
||||
|
||||
### 4.1 Site (`spec.site`)
|
||||
|
||||
```yaml
|
||||
site:
|
||||
title: string # Required
|
||||
description: string # Optional
|
||||
ui: shadcn # UI framework hint; default shadcn
|
||||
routeBaseDocs: /docs # Base path for documentation routes
|
||||
```
|
||||
|
||||
### 4.2 Theme (`spec.theme`)
|
||||
|
||||
Theme MAY use preset shorthand or reference a DTCG token file:
|
||||
|
||||
```yaml
|
||||
theme:
|
||||
preset: zinc # Tailwind-style preset name
|
||||
radius: md # none | sm | md | lg | xl
|
||||
darkMode: true
|
||||
# OR
|
||||
tokens: $yaml: ./theme.tokens.json # DTCG 2025.10 file
|
||||
```
|
||||
|
||||
### 4.3 Navigation
|
||||
|
||||
ADP separates **top tab bar** from **sidebar source**:
|
||||
|
||||
```yaml
|
||||
navigation:
|
||||
tabs: # Top tab bar (Mintlify-style)
|
||||
- tab: Documentation
|
||||
groups:
|
||||
- group: Getting Started
|
||||
pages: [index, getting-started/installation]
|
||||
|
||||
content:
|
||||
docs:
|
||||
dir: ./docs
|
||||
nav:
|
||||
mode: auto # auto | manual — sidebar source
|
||||
sort: filesystem
|
||||
collapsible: true
|
||||
maxDepth: 4
|
||||
```
|
||||
|
||||
- **`navigation.tabs`** — top-level tab bar with grouped pages
|
||||
- **`content.docs.nav.mode: auto`** — sidebar generated from filesystem scan
|
||||
- **`content.docs.nav.mode: manual`** — sidebar driven by `navigation.tabs` groups
|
||||
- Renderers MUST honour `content.docs.nav.mode` when building sidebar (R29)
|
||||
|
||||
### 4.4 Components (`spec.components`)
|
||||
|
||||
```yaml
|
||||
components:
|
||||
header_main:
|
||||
type: Header # MUST exist in component catalog
|
||||
props:
|
||||
logoText: PraisonAIUI
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Component IDs MUST be unique within the manifest
|
||||
- `type` MUST match an entry in the published component catalog
|
||||
- `props` MUST validate against the catalog JSON Schema for that type
|
||||
|
||||
### 4.5 Templates (`spec.templates`)
|
||||
|
||||
```yaml
|
||||
templates:
|
||||
docs:
|
||||
layout: ThreeColumnLayout
|
||||
slots:
|
||||
header: { ref: header_main }
|
||||
left: { ref: sidebar_docs }
|
||||
main: { type: DocContent }
|
||||
right: null # Explicitly hide slot
|
||||
|
||||
landing:
|
||||
layout: FlexibleLayout
|
||||
zones:
|
||||
hero:
|
||||
- type: HeroBanner
|
||||
props: { title: "Welcome" }
|
||||
```
|
||||
|
||||
Rules:
|
||||
- `layout` MUST be a known layout type in the catalog
|
||||
- Each slot value MUST be `{ ref: id }`, `{ type: ComponentType }`, or `null`
|
||||
- Zones MUST use zone names valid for `FlexibleLayout`
|
||||
- A template MUST declare either `slots` or `zones`, not both (R13)
|
||||
|
||||
### 4.6 Routes (`spec.routes`)
|
||||
|
||||
```yaml
|
||||
routes:
|
||||
- match: /docs/changelog
|
||||
template: changelog
|
||||
slots: # Optional per-route slot overrides
|
||||
right: null
|
||||
- match: /docs/**
|
||||
template: docs
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Routes MUST be ordered most-specific-first
|
||||
- `match` MUST be a glob pattern
|
||||
- `template` MUST reference a key in `spec.templates`
|
||||
- Renderers MUST resolve routes at runtime using compiled `route-manifest.json`
|
||||
|
||||
### 4.7 Content (`spec.content`)
|
||||
|
||||
```yaml
|
||||
content:
|
||||
docs:
|
||||
dir: ./docs
|
||||
include: ["**/*.md", "**/*.mdx"]
|
||||
exclude: []
|
||||
nav:
|
||||
mode: auto
|
||||
sort: filesystem
|
||||
collapsible: true
|
||||
maxDepth: 4
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Component catalog
|
||||
|
||||
Implementations MUST publish a `component-catalog.schema.json` (meta-schema) and a catalog instance such as [`catalogs/default.catalog.json`](./catalogs/default.catalog.json):
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "adp-default-catalog",
|
||||
"version": "1.0.0",
|
||||
"components": {
|
||||
"Header": {
|
||||
"description": "Site header with logo and navigation",
|
||||
"props": {
|
||||
"type": "object",
|
||||
"properties": { "logoText": { "type": "string" } },
|
||||
"required": ["logoText"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"aria": { "role": "banner" },
|
||||
"slots": ["header"]
|
||||
}
|
||||
},
|
||||
"layouts": {
|
||||
"ThreeColumnLayout": {
|
||||
"description": "Header + sidebar + main + TOC + footer",
|
||||
"slots": ["header", "left", "main", "right", "footer"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Custom component types MUST be registered via plugin schema layers. Unknown types MUST cause validation failure in strict mode.
|
||||
|
||||
---
|
||||
|
||||
## 6. Compilation pipeline
|
||||
|
||||
A conforming compiler MUST:
|
||||
|
||||
1. Parse YAML or JSON manifest
|
||||
2. Normalise to JSON
|
||||
3. Validate against `application.schema.json` (JSON Schema 2020-12)
|
||||
4. Validate cross-reference rules (see [validation-rules.md](./validation-rules.md))
|
||||
5. Validate component props against catalog schema
|
||||
6. Emit runtime manifests:
|
||||
- `ui-config.json` — site, components, templates, theme, navigation
|
||||
- `route-manifest.json` — ordered routes with template refs
|
||||
- `docs-nav.json` — filesystem-derived navigation (if mode auto)
|
||||
|
||||
---
|
||||
|
||||
## 7. File composition
|
||||
|
||||
Manifests MAY include external files using Backstage-style placeholders:
|
||||
|
||||
```yaml
|
||||
theme:
|
||||
$yaml: ./theme.yaml
|
||||
|
||||
navigation:
|
||||
$yaml: ./navigation.yaml
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Paths MUST be relative to the containing manifest
|
||||
- Path traversal (`../../`) MUST be rejected
|
||||
- Supported placeholders: `$yaml:`, `$json:`, `$text:`
|
||||
|
||||
---
|
||||
|
||||
## 8. Extensions
|
||||
|
||||
Vendor extensions MUST use the `x-adp-` prefix:
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
site:
|
||||
x-adp-customAnalytics: true
|
||||
```
|
||||
|
||||
Implementations MUST preserve unknown `x-adp-*` fields. Non-prefixed unknown fields MUST be rejected in strict mode.
|
||||
|
||||
---
|
||||
|
||||
## 9. Conformance
|
||||
|
||||
An implementation is **ADP-conformant** if and only if it:
|
||||
|
||||
1. Accepts manifests with `apiVersion: adp.io/v1alpha1` and `kind: Application`
|
||||
2. Validates all MUST-level requirements in this spec and validation-rules.md
|
||||
3. Rejects manifests with unknown fields in strict mode
|
||||
4. Emits runtime JSON manifests from valid Application specs
|
||||
5. Resolves routes to templates at render time
|
||||
|
||||
---
|
||||
|
||||
## 10. Related documents
|
||||
|
||||
- [Research report](../application-design-protocol-report.md)
|
||||
- [Implementation status](./implementation-status.md)
|
||||
- [PraisonAIUI mapping](./aiui-mapping.md)
|
||||
- [JSON Schema: application](./schema/application.schema.json)
|
||||
- [JSON Schema: component catalog](./schema/component-catalog.schema.json)
|
||||
- [JSON Schema: theme](./schema/theme.schema.json)
|
||||
- [Examples](./examples/)
|
||||
@@ -0,0 +1,115 @@
|
||||
# ADP Validation Rules
|
||||
|
||||
Cross-reference validation rules for Application Design Protocol manifests. These constraints cannot be expressed in JSON Schema alone and MUST be enforced by conforming compilers.
|
||||
|
||||
Rules use RFC 2119 keywords: MUST, MUST NOT, SHOULD, MAY.
|
||||
|
||||
Inspired by AWP validation rules R1–R32 pattern.
|
||||
|
||||
---
|
||||
|
||||
## Envelope rules
|
||||
|
||||
| Rule | Level | Description |
|
||||
|------|-------|-------------|
|
||||
| **R1** | MUST | `schemaVersion` MUST equal `1` for `apiVersion: adp.io/v1alpha1` |
|
||||
| **R2** | MUST | `kind` MUST be `Application` for root manifests |
|
||||
| **R3** | MUST | `metadata.name` MUST be unique within a deployment scope |
|
||||
| **R4** | MUST NOT | Unknown top-level fields outside `x-adp-*` namespace in strict mode |
|
||||
|
||||
## Component rules
|
||||
|
||||
| Rule | Level | Description |
|
||||
|------|-------|-------------|
|
||||
| **R5** | MUST | Every `spec.components` entry MUST have a unique ID |
|
||||
| **R6** | MUST | Every component `type` MUST exist in the published component catalog |
|
||||
| **R7** | MUST | Component `props` MUST validate against catalog JSON Schema for that type |
|
||||
| **R8** | MUST NOT | A component ID be referenced by `ref:` unless it exists in `spec.components` |
|
||||
| **R9** | SHOULD | Unused components (defined but never referenced) SHOULD emit a warning |
|
||||
|
||||
## Template rules
|
||||
|
||||
| Rule | Level | Description |
|
||||
|------|-------|-------------|
|
||||
| **R10** | MUST | Every `spec.templates` entry MUST declare a valid `layout` from the catalog |
|
||||
| **R11** | MUST | Slot names in `templates.*.slots` MUST be valid for the declared layout |
|
||||
| **R12** | MUST | Zone names in `templates.*.zones` MUST be valid for `FlexibleLayout` |
|
||||
| **R13** | MUST | Each template MUST declare either `slots` or `zones`, not both |
|
||||
| **R14** | MUST | Inline slot `{ type: X }` types MUST exist in the component catalog |
|
||||
| **R15** | MUST | Slot `{ ref: id }` MUST resolve to a component in `spec.components` |
|
||||
|
||||
## Route rules
|
||||
|
||||
| Rule | Level | Description |
|
||||
|------|-------|-------------|
|
||||
| **R16** | MUST | Every route `template` MUST reference a key in `spec.templates` |
|
||||
| **R17** | MUST | Routes MUST be ordered most-specific-first (longer/more precise patterns before wildcards) |
|
||||
| **R18** | MUST | Route slot overrides MUST use valid slot names for the target template's layout |
|
||||
| **R19** | SHOULD | At least one route SHOULD match the site root or docs base path |
|
||||
|
||||
## Content and navigation rules
|
||||
|
||||
| Rule | Level | Description |
|
||||
|------|-------|-------------|
|
||||
| **R20** | MUST | If `content.docs.dir` is declared, the directory MUST exist at compile time |
|
||||
| **R21** | MUST | If `content.docs.nav.mode` is `manual`, `navigation.tabs` MUST be non-empty |
|
||||
| **R22** | SHOULD | If `content.docs.nav.mode` is `auto`, a content source MUST be declared |
|
||||
|
||||
Navigation model:
|
||||
|
||||
- **`navigation.tabs`** — top tab bar (Mintlify-style)
|
||||
- **`content.docs.nav.mode`** — sidebar source: `auto` (filesystem scan) or `manual` (use `navigation.tabs` groups)
|
||||
|
||||
## File include rules
|
||||
|
||||
| Rule | Level | Description |
|
||||
|------|-------|-------------|
|
||||
| **R23** | MUST NOT | `$yaml:`, `$json:`, or `$text:` paths traverse outside the project root |
|
||||
| **R24** | MUST | Included files MUST parse as valid YAML/JSON/text respectively |
|
||||
| **R25** | MUST NOT | Circular file includes (A includes B includes A) |
|
||||
|
||||
## Theme rules
|
||||
|
||||
| Rule | Level | Description |
|
||||
|------|-------|-------------|
|
||||
| **R26** | MUST | If `theme.tokens` is declared, the referenced file MUST exist and validate against DTCG 2025.10 |
|
||||
| **R27** | MUST NOT | Both `theme.preset` and `theme.tokens` be declared simultaneously |
|
||||
|
||||
## Renderer conformance (target — not yet implemented in PraisonAIUI)
|
||||
|
||||
These rules define the target behaviour for ADP-conformant renderers. See [implementation-status.md](./implementation-status.md).
|
||||
|
||||
| Rule | Level | Description |
|
||||
|------|-------|-------------|
|
||||
| **R28** | MUST | Renderers MUST resolve the active template from `route-manifest.json` at runtime |
|
||||
| **R29** | MUST | Renderers MUST honour `content.docs.nav.mode` when building sidebar |
|
||||
| **R30** | MUST | Renderers MUST apply ARIA roles from catalog for each rendered component |
|
||||
|
||||
---
|
||||
|
||||
## Validation pipeline order
|
||||
|
||||
```
|
||||
1. Parse YAML/JSON
|
||||
2. Resolve $yaml/$json/$text includes (R23–R25)
|
||||
3. JSON Schema validate (application.schema.json)
|
||||
4. Cross-ref rules R1–R27
|
||||
5. Component props validate against catalog (R6–R7)
|
||||
6. Emit runtime manifests
|
||||
7. Runtime conformance R28–R30 (renderer responsibility)
|
||||
```
|
||||
|
||||
## Error format
|
||||
|
||||
Validation failures MUST report:
|
||||
|
||||
```json
|
||||
{
|
||||
"rule": "R8",
|
||||
"path": "spec.templates.docs.slots.header.ref",
|
||||
"message": "Component ref 'missing_header' not found in spec.components",
|
||||
"severity": "error"
|
||||
}
|
||||
```
|
||||
|
||||
Warnings (R9) use `"severity": "warning"`.
|
||||
@@ -0,0 +1,427 @@
|
||||
# Application Design Protocol (ADP) — Research Report
|
||||
|
||||
**Version:** 1.0 (draft)
|
||||
**Date:** August 2026
|
||||
**Scope:** UI, layout, navigation, theming — declarative application design from text manifests
|
||||
**Status:** Research and recommendation; not yet a published standard
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive summary
|
||||
|
||||
There is no single existing standard for **designing full application UI from a declarative text file**. Instead, the problem is addressed in fragments:
|
||||
|
||||
- **Docs-site generators** (Mintlify, PraisonAIUI, Hugo) declare navigation and layout in config files.
|
||||
- **Form libraries** (JSON Forms, RJSF) declare layout trees from JSON Schema.
|
||||
- **Agent UI protocols** (A2UI, AG-UI) declare streaming component surfaces for LLM-generated UI.
|
||||
- **Catalog standards** (OpenUI, Backstage) declare component metadata without runtime layout.
|
||||
- **Design token standards** (DTCG 2025.10) declare theming without structure.
|
||||
|
||||
**Recommendation:** Define an **Application Design Protocol (ADP)** that synthesises proven patterns from 46 surveyed standards into a UI-only manifest format.
|
||||
|
||||
PraisonAIUI already implements approximately 60% of this model. ADP formalises, extends, and publishes what exists today.
|
||||
|
||||
---
|
||||
|
||||
## 2. Problem statement
|
||||
|
||||
### 2.1 What we need
|
||||
|
||||
A protocol that lets a developer (or LLM) describe an application's visual design — pages, navigation, components, themes, routes — in **one or more text files**, without writing application code.
|
||||
|
||||
### 2.2 What exists today (fragmented)
|
||||
|
||||
| Need | Who solves it | Gap |
|
||||
|------|---------------|-----|
|
||||
| Docs site from config | Mintlify, PraisonAIUI, Docusaurus | No shared spec; Mintlify uses JSON, others use YAML or JS |
|
||||
| Component catalog | OpenUI, A2UI | No layout or routing |
|
||||
| Layout trees | JSON Forms uiSchema | Forms only, not app shells |
|
||||
| Theming | DTCG, Material 3 | Tokens only, no pages |
|
||||
| Agent-generated UI | A2UI | Runtime streaming, not human-authored static sites |
|
||||
| Software metadata | Backstage | Catalog entities, not UI composition |
|
||||
|
||||
### 2.3 Design goals for ADP
|
||||
|
||||
1. **Human-readable** — editable without IDE plugins
|
||||
2. **LLM-safe** — structural schema rejects unknown fields
|
||||
3. **Portable** — same manifest renders on any ADP-compliant runtime
|
||||
4. **Validatable** — fail fast at build time, not in the browser
|
||||
5. **Extensible** — plugins add catalog entries, not inline code
|
||||
6. **Accessible** — ARIA hints required in component catalog
|
||||
|
||||
---
|
||||
|
||||
## 3. Standards landscape — 46 protocols in 10 categories
|
||||
|
||||
Each entry describes **how UI/layout/navigation is declared** — the primary comparison dimension.
|
||||
|
||||
### Category A: Web platform baseline (5)
|
||||
|
||||
| Standard | Format | How UI/layout/navigation is declared | Schema | Versioning |
|
||||
|----------|--------|--------------------------------------|--------|------------|
|
||||
| **HTML + CSS** | `.html` + `.css` | Semantic elements; CSS Flexbox/Grid; `<nav>` + links | Living spec grammar | Browser Baseline |
|
||||
| **Web Components + DSD** | HTML + JS | Custom element tree; slots; Declarative Shadow DOM | DOM IDL | Platform flags |
|
||||
| **WAI-ARIA + APG** | HTML attributes | Landmarks, widget roles, keyboard patterns | WAI-ARIA 1.2 | Rec track |
|
||||
| **DTCG Design Tokens** | JSON | Token groups: `$value`, `$type`, `{alias}` refs — **no layout** | DTCG 2025.10 | Date editions |
|
||||
| **OpenUI** | YAML/JSON | Component catalog: `components{id: props}` — **no layout tree** | Prop definitions | Per-library version |
|
||||
|
||||
### Category B: Schema-driven forms and layout (3)
|
||||
|
||||
| Standard | Format | How UI/layout/navigation is declared | Schema | Versioning |
|
||||
|----------|--------|--------------------------------------|--------|------------|
|
||||
| **JSON Forms** | JSON Schema + UI Schema | `VerticalLayout`, `Control`, `Categorization` with `scope` pointers | JSON Schema + uiSchema | Package SemVer |
|
||||
| **RJSF** | JSON Schema + uiSchema | Schema-tree mirroring; `ui:widget`, `ui:options` | React convention | Package SemVer |
|
||||
| **Google A2UI** | JSONL stream | Flat adjacency-list; `createSurface`; catalog-constrained types | JSON Schema catalog | v0.9.1 / v1.0 RC |
|
||||
|
||||
### Category C: Docs-site generators (6)
|
||||
|
||||
| Standard | Format | How UI/layout/navigation is declared | Schema | Versioning |
|
||||
|----------|--------|--------------------------------------|--------|------------|
|
||||
| **Mintlify** | JSON (`docs.json`) | `navigation.groups[].pages[]`; `$schema` URL; `$ref` splitting | Published JSON Schema URL | Platform |
|
||||
| **PraisonAIUI** | YAML | `components` → `templates{layout, slots, zones}` → `routes` | Pydantic (internal) | `schemaVersion: 1` |
|
||||
| **Docusaurus** | JS config | `themeConfig.navbar`, plugin sidebar config | TypeScript types | Package SemVer |
|
||||
| **Hugo** | TOML/YAML | `menu.main[]`, theme selection | Hugo schema | Release train |
|
||||
| **Jekyll** | YAML | Frontmatter per page; `_data/` nav | Conventions | SemVer |
|
||||
| **Gatsby** | JS/TS | `siteMetadata` + `plugins[]` | JS object | Major versions |
|
||||
|
||||
### Category D: Native declarative UI — reference only (5)
|
||||
|
||||
| Standard | Format | Serialised wire format? | Navigation model |
|
||||
|----------|--------|-------------------------|------------------|
|
||||
| **Jetpack Compose** | Kotlin | No | `NavHost` + routes |
|
||||
| **SwiftUI** | Swift | No | `NavigationStack` |
|
||||
| **Flutter** | Dart | No | `Router` / `Navigator` |
|
||||
| **Qt QML** | `.qml` | Yes | `StackView`, bindings |
|
||||
| **XAML** | XML | Yes (dialect-fragmented) | `Frame`, `NavigationView` |
|
||||
|
||||
### Category E: Design systems (2)
|
||||
|
||||
| Standard | Format | Theming model |
|
||||
|----------|--------|---------------|
|
||||
| **Material Design 3** | DTCG JSON | reference → system → component token tiers |
|
||||
| **Bootstrap 5.3** | SCSS → CSS vars | Sass maps → `--bs-*` custom properties |
|
||||
|
||||
### Category F: Design-to-code bridges — not source-of-truth (3)
|
||||
|
||||
| Standard | Output | Why not ADP source |
|
||||
|----------|--------|-------------------|
|
||||
| **Figma MCP** | React+Tailwind context | Proprietary; bridge only |
|
||||
| **Webflow export** | HTML/CSS/JS ZIP | No schema; paid export |
|
||||
| **Framer export** | React bundles | Platform lock-in |
|
||||
|
||||
### Category G: Component documentation (1)
|
||||
|
||||
| Standard | Format | Navigation |
|
||||
|----------|--------|------------|
|
||||
| **Storybook CSF 3** | TS modules | Sidebar via `title` path hierarchy |
|
||||
|
||||
### Category H: Configuration and packaging (12)
|
||||
|
||||
| Standard | Format | Composition mechanism | ADP borrow |
|
||||
|----------|--------|----------------------|------------|
|
||||
| **Kubernetes CRD** | YAML | `apiVersion/kind/spec`; structural OpenAPI | Structural validation |
|
||||
| **Helm** | YAML + templates | Subcharts; values merge; `values.schema.json` | Defaults + overrides |
|
||||
| **Backstage catalog** | YAML | `$yaml:`/`$json:` includes; relation graph | Envelope + includes |
|
||||
| **OpenAPI 3.1** | YAML/JSON | `$ref`, `components` registry | JSON Schema foundation |
|
||||
| **AsyncAPI 3.x** | YAML/JSON | Channels → messages | Future event UI |
|
||||
| **JSON Schema 2020-12** | JSON | `$ref`, `$defs`, conditionals | Validation dialect |
|
||||
| **CloudEvents** | JSON envelope | Fixed envelope + opaque payload | Future event wrapper |
|
||||
| **Docker Compose** | YAML | Multi-file merge; profiles | Override stacking |
|
||||
| **CUE** | CUE | Unify schema + data | Power-user compile layer |
|
||||
| **Jsonnet** | Jsonnet → JSON | `import` + merge | Theme variants |
|
||||
| **Pkl** | `.pkl` | `amends`, mixins | Typed config model |
|
||||
| **OCI Artifact** | JSON manifest | Multi-layer blobs | Bundle packaging |
|
||||
|
||||
### Category I: Agent and workflow protocols (7)
|
||||
|
||||
| Standard | Format | UI/state declaration | ADP borrow |
|
||||
|----------|--------|---------------------|------------|
|
||||
| **MCP** | JSON-RPC | Tool schemas — no UI layout | Future widget actions |
|
||||
| **AG-UI** | Streaming JSON | EventType enum; snapshots + deltas | Streaming model |
|
||||
| **A2A** | Proto → JSON-RPC | Agent Cards | Transport |
|
||||
| **AWP** | Multi-file YAML | 7 layers; rules R1–R32 | Layer + validation model |
|
||||
| **LangGraph** | workflow.yaml | Graph nodes + edges | Layout graph |
|
||||
| **AutoGen** | ComponentModel JSON | `{provider, type, version, config}` | Pluggable components |
|
||||
| **CrewAI** | JSONC/YAML | Agent arrays — weak schema | Anti-pattern |
|
||||
|
||||
### Category J: Application packaging (2)
|
||||
|
||||
| Standard | Format | Application declaration |
|
||||
|----------|--------|------------------------|
|
||||
| **OAM** | YAML | `Application` with `components[]`, `traits[]` |
|
||||
| **CNAB** | bundle.json | Parameters, credentials, actions |
|
||||
|
||||
---
|
||||
|
||||
## 4. How they do it — mechanism comparison matrix
|
||||
|
||||
| Mechanism | Standards | How it works | ADP |
|
||||
|-----------|-----------|--------------|-----|
|
||||
| **Envelope document** | K8s, Backstage, OAM, AWP | `apiVersion` + `kind` + `metadata` + `spec` | Adopt |
|
||||
| **Component registry + ref** | PraisonAIUI, OpenUI | Named map; templates use `ref:` | Adopt |
|
||||
| **Layout + slots** | PraisonAIUI, JSON Forms | Layout type + named regions | Adopt (unified) |
|
||||
| **Route → template** | PraisonAIUI, Flutter | URL glob → template | Adopt |
|
||||
| **Navigation tree** | Mintlify, Storybook | Hierarchical groups → pages | Adopt |
|
||||
| **Design token file** | DTCG, Material 3 | Token JSON with aliases | Adopt |
|
||||
| **Catalog allowlist** | A2UI, OpenUI | Closed component vocabulary | Adopt |
|
||||
| **Schema + uiSchema split** | JSON Forms, RJSF | Data vs presentation | Partial |
|
||||
| **Streaming surfaces** | A2UI, AG-UI | Incremental JSON updates | Future |
|
||||
| **Layered spec** | AWP, Helm | Separate files merged at compile | Adopt |
|
||||
| **File includes** | Backstage, Mintlify | `$yaml:` / `$ref` external files | Adopt |
|
||||
| **Structural pruning** | K8s CRD | Reject unknown fields | Adopt |
|
||||
| **Validation rules R1–Rn** | AWP | Cross-ref beyond JSON Schema | Adopt |
|
||||
| **JS executable config** | Docusaurus, Gatsby | Code config | Reject |
|
||||
| **Visual canvas** | Figma, Webflow | Proprietary designer | Reject |
|
||||
| **Code-only UI** | Compose, SwiftUI | No wire format | Out of scope |
|
||||
|
||||
---
|
||||
|
||||
## 5. Side-by-side: four closest analogues
|
||||
|
||||
| Dimension | PraisonAIUI | Mintlify | A2UI | Backstage |
|
||||
|-----------|-------------|----------|------|-----------|
|
||||
| **Authoring format** | YAML | JSON | JSON (stream) | YAML |
|
||||
| **Envelope** | `schemaVersion` only | Flat JSON | Message `version` | `apiVersion/kind/metadata/spec` |
|
||||
| **Navigation** | `navigation.tabs[]` + filesystem | `navigation.groups[]` | N/A | N/A (metadata catalog) |
|
||||
| **Components** | `components{id: type, props}` | N/A | Catalog + adjacency list | Entity kinds |
|
||||
| **Layout** | `templates{layout, slots, zones}` | Theme only | Surfaces | N/A |
|
||||
| **Routes** | `routes{match, template}` | Page paths in nav | N/A | N/A |
|
||||
| **Schema published** | Pydantic only | `$schema` URL | JSON Schema catalog | JSON Schema per kind |
|
||||
| **Runtime** | React SPA | Hosted platform | Any renderer | Catalog API |
|
||||
| **Primary use** | Docs sites | Docs sites | Agent-generated UI | Software inventory |
|
||||
|
||||
**Synthesis:** ADP combines PraisonAIUI's composition model, Mintlify's schema publishing, A2UI's catalog allowlist, and Backstage's envelope and file includes.
|
||||
|
||||
---
|
||||
|
||||
## 6. YAML vs alternatives — structured debate
|
||||
|
||||
### 6.1 The assumption
|
||||
|
||||
YAML is the best authoring format for application design manifests.
|
||||
|
||||
### 6.2 Counter-evidence
|
||||
|
||||
**Mintlify** — the closest commercial docs-site analogue — uses **JSON** (`docs.json`) with a published `$schema` URL for IDE autocomplete. This works well for flat-to-moderately-nested config but becomes verbose for deep layout trees.
|
||||
|
||||
**A2UI** uses **JSON** as both authoring and wire format because LLMs emit structured JSON reliably and ambiguity is unacceptable at runtime.
|
||||
|
||||
**Docusaurus and Gatsby** use **JavaScript/TypeScript** config for programmatic power — but this sacrifices portability and LLM generation safety.
|
||||
|
||||
### 6.3 Format comparison
|
||||
|
||||
| Format | Strengths | Weaknesses for ADP |
|
||||
|--------|-----------|-------------------|
|
||||
| **YAML** | Comments; readable nesting; LLM-friendly; PraisonAIUI continuity | Ambiguous typing; merge key footguns; no native DRY |
|
||||
| **JSON** | Unambiguous; `$schema` IDE support; Mintlify-proven | No comments; verbose for layout trees |
|
||||
| **TOML** | Clear flat tables (Hugo) | Poor for nested templates/zones |
|
||||
| **CUE / Jsonnet / Pkl** | Typed composition without templating | Learning curve; not LLM-default |
|
||||
| **HCL** | Modules, expressions | Ecosystem-bound; overkill for UI-only |
|
||||
| **JS/TS** | Full programmatic power | Not portable; not LLM-safe |
|
||||
|
||||
### 6.4 Recommendation
|
||||
|
||||
```
|
||||
Author (human/LLM) → YAML (default) or JSON (alternate)
|
||||
↓
|
||||
ADP compiler/validator (JSON Schema 2020-12)
|
||||
↓
|
||||
Normalised JSON manifest (runtime)
|
||||
↓
|
||||
UI renderer (catalog-constrained)
|
||||
```
|
||||
|
||||
- **Primary authoring:** YAML — best for nested `templates` / `zones` trees
|
||||
- **Alternate authoring:** JSON — for teams preferring Mintlify-style `$schema` tooling
|
||||
- **Never at runtime:** YAML — always compile to JSON before serve
|
||||
- **Composition:** `$yaml:` file includes (Backstage), not Helm Go templates
|
||||
- **Power users:** Optional CUE/Jsonnet compiles to YAML before validation
|
||||
|
||||
**Verdict:** YAML remains the recommended default, but ADP MUST accept JSON as an equivalent authoring format with identical semantics.
|
||||
|
||||
---
|
||||
|
||||
## 7. ADP design principles
|
||||
|
||||
1. **Declarative, not executable** — manifests describe intent; runtimes render; no inline code or templating logic in core spec.
|
||||
2. **Schema-first** — JSON Schema 2020-12 is canonical; YAML is authoring convenience.
|
||||
3. **Structural strictness** — unknown fields rejected in strict mode (K8s CRD pattern).
|
||||
4. **Catalog allowlist** — component `type` must exist in published catalog (A2UI pattern).
|
||||
5. **Envelope consistency** — every document uses `apiVersion`, `kind`, `metadata`, `spec`.
|
||||
6. **Separation of concerns** — theme, navigation, components, templates, routes are distinct spec sections.
|
||||
7. **Compile, don't interpret** — YAML → validated JSON manifest → static assets at build time.
|
||||
8. **Accessibility by default** — catalog entries include ARIA role hints (WAI-ARIA pattern).
|
||||
9. **Versioned protocol** — `schemaVersion` integer + `apiVersion` string; breaking changes bump version.
|
||||
10. **Extension namespace** — `x-adp-*` prefix reserved; plugins register catalog layers.
|
||||
11. **File composition** — `$yaml:` includes for modular configs (Backstage pattern).
|
||||
12. **Route-driven rendering** — URL resolution selects template; no hardcoded layout in renderer.
|
||||
|
||||
---
|
||||
|
||||
## 8. Proposed ADP document model
|
||||
|
||||
```yaml
|
||||
schemaVersion: 1
|
||||
apiVersion: adp.io/v1alpha1
|
||||
kind: Application
|
||||
|
||||
metadata:
|
||||
name: my-docs-site
|
||||
version: "1.0.0"
|
||||
labels: {}
|
||||
|
||||
spec:
|
||||
site:
|
||||
title: "My Site"
|
||||
description: "..."
|
||||
theme:
|
||||
preset: zinc # or DTCG token ref
|
||||
radius: md
|
||||
darkMode: true
|
||||
navigation:
|
||||
mode: auto # auto | manual
|
||||
tabs: [...]
|
||||
components:
|
||||
header_main:
|
||||
type: Header
|
||||
props: { logoText: "..." }
|
||||
templates:
|
||||
docs:
|
||||
layout: ThreeColumnLayout
|
||||
slots:
|
||||
header: { ref: header_main }
|
||||
main: { type: DocContent }
|
||||
routes:
|
||||
- match: "/docs/**"
|
||||
template: docs
|
||||
content:
|
||||
docs:
|
||||
dir: "./docs"
|
||||
```
|
||||
|
||||
### Data flow
|
||||
|
||||
```
|
||||
application.yaml → Parse → JSON Schema validate → Cross-ref rules (R1–R30)
|
||||
→ Compile → ui-config.json + route-manifest.json + docs-nav.json
|
||||
→ React / static renderer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. PraisonAIUI alignment and gaps
|
||||
|
||||
### Already aligned
|
||||
|
||||
| PraisonAIUI today | ADP equivalent |
|
||||
|-------------------|----------------|
|
||||
| `schemaVersion: 1` | `schemaVersion` + `apiVersion` |
|
||||
| `components` + `ref:` | `spec.components` + slot refs |
|
||||
| `templates.layout` + `slots` | `spec.templates` |
|
||||
| `routes` | `spec.routes` |
|
||||
| `site.theme` | `spec.theme` |
|
||||
| Compiler → JSON manifests | ADP compile pipeline |
|
||||
| Pydantic validation | JSON Schema + cross-ref rules |
|
||||
|
||||
### Gaps to close
|
||||
|
||||
| Gap | ADP fix |
|
||||
|-----|---------|
|
||||
| No published JSON Schema | Publish `application.schema.json` |
|
||||
| `navigation.tabs` compiled but not rendered | Mandate runtime consumption in spec |
|
||||
| `route-manifest.json` not used for routing | Mandate route-driven template resolution |
|
||||
| Dual slots + zones models | Unified: zones = ordered slot arrays |
|
||||
| Closed hardcoded component types | Published `component-catalog.schema.json` |
|
||||
| No envelope (`kind`, `metadata`) | Add Backstage-style envelope |
|
||||
|
||||
---
|
||||
|
||||
## 10. Migration path
|
||||
|
||||
Existing `aiui.template.yaml` files remain valid. Migration steps:
|
||||
|
||||
1. Add `apiVersion: adp.io/v1alpha1` and `kind: Application` (optional during transition).
|
||||
2. Wrap top-level keys under `spec:` (compiler accepts both shapes during v1alpha1).
|
||||
3. Rename `site` fields unchanged; map `navigation`, `templates`, `routes` as-is.
|
||||
4. Publish catalog; register custom component types via plugin schema layers.
|
||||
|
||||
See [adp/aiui-mapping.md](./adp/aiui-mapping.md) for field-by-field mapping.
|
||||
|
||||
---
|
||||
|
||||
## 11. Open questions
|
||||
|
||||
1. **Should ADP standardise layout type names** (`ThreeColumnLayout`) or allow runtime-defined layouts?
|
||||
2. **Should navigation support both filesystem-auto and manual tabs** in one spec, or require a mode switch?
|
||||
3. **Should agent-driven surfaces (A2UI) be a separate `kind: Surface`** or embedded in Application?
|
||||
4. **Should theme reference DTCG token files directly** or use simplified preset shorthand?
|
||||
|
||||
---
|
||||
|
||||
## 12. Appendix A — Standards index
|
||||
|
||||
| # | Standard | URL |
|
||||
|---|----------|-----|
|
||||
| 1 | A2UI | https://a2ui.org/ |
|
||||
| 2 | AG-UI | https://docs.ag-ui.com/ |
|
||||
| 3 | AWP | https://github.com/veegee82/agent-workflow-protocol |
|
||||
| 4 | AsyncAPI | https://www.asyncapi.com/ |
|
||||
| 5 | Backstage catalog | https://backstage.io/docs/features/software-catalog/descriptor-format |
|
||||
| 6 | Bootstrap | https://getbootstrap.com/ |
|
||||
| 7 | CloudEvents | https://cloudevents.io/ |
|
||||
| 8 | CNAB | https://cnab.io/ |
|
||||
| 9 | CrewAI | https://docs.crewai.com/ |
|
||||
| 10 | CUE | https://cuelang.org/ |
|
||||
| 11 | Docusaurus | https://docusaurus.io/ |
|
||||
| 12 | DTCG Design Tokens | https://www.designtokens.org/ |
|
||||
| 13 | Figma Code Connect | https://developers.figma.com/docs/code-connect/ |
|
||||
| 14 | Framer | https://www.framer.com/ |
|
||||
| 15 | Gatsby | https://www.gatsbyjs.com/ |
|
||||
| 16 | Helm | https://helm.sh/ |
|
||||
| 17 | Hugo | https://gohugo.io/ |
|
||||
| 18 | Jekyll | https://jekyllrb.com/ |
|
||||
| 19 | JSON Forms | https://jsonforms.io/ |
|
||||
| 20 | JSON Schema | https://json-schema.org/ |
|
||||
| 21 | Kubernetes CRD | https://kubernetes.io/docs/concepts/extend-kubernetes/api-extension/custom-resources/ |
|
||||
| 22 | LangGraph | https://langchain-ai.github.io/langgraph/ |
|
||||
| 23 | Material Design 3 | https://m3.material.io/ |
|
||||
| 24 | MCP | https://modelcontextprotocol.io/ |
|
||||
| 25 | Mintlify | https://mintlify.com/docs |
|
||||
| 26 | OAM | https://oam.dev/ |
|
||||
| 27 | OCI Artifact | https://opencontainers.org/ |
|
||||
| 28 | OpenAPI | https://www.openapis.org/ |
|
||||
| 29 | OpenUI | https://openuispec.org/ |
|
||||
| 30 | Pkl | https://pkl-lang.org/ |
|
||||
| 31 | PraisonAIUI | https://github.com/MervinPraison/PraisonAIUI |
|
||||
| 32 | RJSF | https://rjsf-team.github.io/react-jsonschema-form/ |
|
||||
| 33 | Storybook CSF | https://storybook.js.org/docs/api/csf |
|
||||
| 34 | W3C ARIA APG | https://www.w3.org/WAI/ARIA/apg/ |
|
||||
| 35 | Web Components | https://web.dev/articles/declarative-shadow-dom |
|
||||
| 36 | Webflow | https://webflow.com/ |
|
||||
| 37 | AutoGen | https://microsoft.github.io/autogen/ |
|
||||
| 38 | A2A | https://google.github.io/A2A/ |
|
||||
| 39 | Jsonnet | https://jsonnet.org/ |
|
||||
| 40 | Docker Compose | https://docs.docker.com/compose/ |
|
||||
| 41 | Qt QML | https://doc.qt.io/qt-6/qmlapplications.html |
|
||||
| 42 | XAML | https://learn.microsoft.com/en-us/windows/apps/develop/platform/xaml/xaml-overview |
|
||||
| 43 | Jetpack Compose | https://developer.android.com/develop/ui/compose |
|
||||
| 44 | SwiftUI | https://developer.apple.com/xcode/swiftui/ |
|
||||
| 45 | HTML + CSS | https://html.spec.whatwg.org/ |
|
||||
| 46 | Flutter | https://flutter.dev/ |
|
||||
|
||||
---
|
||||
|
||||
## 13. Conclusion
|
||||
|
||||
ADP is feasible and largely prefigured by PraisonAIUI's existing architecture. The research across 46 standards yields a clear synthesis:
|
||||
|
||||
- **Envelope** from Backstage/K8s
|
||||
- **Composition** from PraisonAIUI (components → templates → routes)
|
||||
- **Catalog** from A2UI/OpenUI
|
||||
- **Theming** from DTCG
|
||||
- **Validation** from AWP (structural schema + R-rules)
|
||||
- **Authoring** in YAML (default) with JSON alternate (Mintlify pattern)
|
||||
|
||||
**Status:** Research draft — see [adp/implementation-status.md](./adp/implementation-status.md) for implementation gaps.
|
||||
|
||||
The draft spec, JSON Schema files, and example manifests are published alongside this report in [`docs/protocols/adp/`](./adp/).
|
||||
@@ -25,6 +25,7 @@ from praisonaiui.server import create_app
|
||||
|
||||
# Use shared seed data helper
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from _shared.console import safe_print as print
|
||||
from _shared.seed_data import seed_demo_data
|
||||
|
||||
seed_demo_data()
|
||||
|
||||
@@ -20,6 +20,7 @@ import sys
|
||||
|
||||
# Use shared seed data helper
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from _shared.console import safe_print as print
|
||||
from _shared.seed_data import seed_demo_data
|
||||
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import time
|
||||
import praisonaiui as aiui
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '_shared'))
|
||||
from console import safe_print as print
|
||||
from stream_bridge import StreamBridge
|
||||
|
||||
try:
|
||||
|
||||
@@ -4,9 +4,15 @@ Demonstrates ALL 16 PraisonAIUI features with real API calls.
|
||||
Run: aiui run app.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
from praisonaiui.server import create_app
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from _shared.console import safe_print as print
|
||||
|
||||
|
||||
def test_all_features():
|
||||
"""Test all 16 feature APIs with real data."""
|
||||
|
||||
@@ -44,15 +44,18 @@ import sys
|
||||
# ── Import praisonaiui ──────────────────────────────────────
|
||||
import praisonaiui as aiui
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from _shared.console import icon, safe_print
|
||||
|
||||
# ── Check gateway availability ──────────────────────────────
|
||||
try:
|
||||
from praisonaiui.integration import AIUIGateway
|
||||
from praisonaiagents import Agent
|
||||
GATEWAY_OK = True
|
||||
except ImportError as e:
|
||||
print(f"⚠️ Gateway dependencies missing: {e}")
|
||||
print(" Install with: pip install praisonai praisonaiagents")
|
||||
print(" Falling back to standalone mode.")
|
||||
safe_print(f"{icon('⚠️', '[WARN]')} Gateway dependencies missing: {e}")
|
||||
safe_print(" Install with: pip install praisonai praisonaiagents")
|
||||
safe_print(" Falling back to standalone mode.")
|
||||
GATEWAY_OK = False
|
||||
|
||||
# ── Set dashboard style ─────────────────────────────────────
|
||||
@@ -149,15 +152,15 @@ def _register_agents_in_dashboard():
|
||||
"model": agent_def["model"],
|
||||
"icon": agent_def["icon"],
|
||||
})
|
||||
print(f" ✓ Dashboard: {agent_def['icon']} {agent_def['name']}")
|
||||
safe_print(f" {icon('✓', '[OK]')} Dashboard: {agent_def['name']}")
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────
|
||||
async def main():
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
if GATEWAY_OK:
|
||||
print("🚀 Starting with Gateway mode (full execution + streaming)")
|
||||
print()
|
||||
safe_print(f"{icon('🚀', '[START]')} Starting with Gateway mode (full execution + streaming)")
|
||||
safe_print()
|
||||
|
||||
# Create the gateway
|
||||
gateway = AIUIGateway(
|
||||
@@ -174,22 +177,22 @@ async def main():
|
||||
self_reflect=False,
|
||||
)
|
||||
gateway.register_agent(agent, agent_id=agent_def["agent_id"])
|
||||
print(f" ✓ Gateway: {agent_def['icon']} {agent_def['name']} ({agent_def['model']})")
|
||||
safe_print(f" {icon('✓', '[OK]')} Gateway: {agent_def['name']} ({agent_def['model']})")
|
||||
|
||||
# ── Register agents in dashboard CRUD storage ───────
|
||||
_register_agents_in_dashboard()
|
||||
|
||||
print()
|
||||
print(f" Dashboard: http://localhost:8083")
|
||||
print(f" WebSocket: ws://localhost:8083/ws")
|
||||
print(f" Inspector: http://localhost:8083 → 🔍 Feature Inspector")
|
||||
print()
|
||||
safe_print()
|
||||
safe_print(" Dashboard: http://localhost:8083")
|
||||
safe_print(" WebSocket: ws://localhost:8083/ws")
|
||||
safe_print(f" Inspector: http://localhost:8083 {icon('→', '->')} {icon('🔍', '')} Feature Inspector")
|
||||
safe_print()
|
||||
|
||||
# Start the gateway — this runs the full server
|
||||
await gateway.start()
|
||||
else:
|
||||
# Fallback to standalone mode (same as example 15)
|
||||
print("📦 Starting in standalone mode (CRUD only, no agent execution)")
|
||||
safe_print(f"{icon('📦', '[INFO]')} Starting in standalone mode (CRUD only, no agent execution)")
|
||||
from praisonaiui.server import create_app
|
||||
import uvicorn
|
||||
|
||||
|
||||
@@ -26,15 +26,18 @@ import sys
|
||||
|
||||
import praisonaiui as aiui
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from _shared.console import icon, safe_print
|
||||
|
||||
# ── Check gateway availability ──────────────────────────────
|
||||
try:
|
||||
from praisonaiui.integration import AIUIGateway
|
||||
from praisonaiagents import Agent
|
||||
GATEWAY_OK = True
|
||||
except ImportError as e:
|
||||
print(f"⚠️ Gateway dependencies not found: {e}")
|
||||
print(" Install with: pip install praisonai praisonaiagents")
|
||||
print(" Falling back to standalone mode (REST APIs only).\n")
|
||||
safe_print(f"{icon('⚠️', '[WARN]')} Gateway dependencies not found: {e}")
|
||||
safe_print(" Install with: pip install praisonai praisonaiagents")
|
||||
safe_print(" Falling back to standalone mode (REST APIs only).\n")
|
||||
GATEWAY_OK = False
|
||||
|
||||
aiui.set_style("dashboard")
|
||||
@@ -115,7 +118,7 @@ def _register_agents_in_dashboard():
|
||||
"model": agent_def["model"],
|
||||
"icon": agent_def["icon"],
|
||||
})
|
||||
print(f" ✓ Agent: {agent_def['icon']} {agent_def['name']}")
|
||||
safe_print(f" {icon('✓', '[OK]')} Agent: {agent_def['name']}")
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────
|
||||
@@ -125,8 +128,8 @@ async def main():
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
|
||||
if GATEWAY_OK:
|
||||
print("🚀 Starting with Gateway mode (full execution + streaming)")
|
||||
print()
|
||||
safe_print(f"{icon('🚀', '[START]')} Starting with Gateway mode (full execution + streaming)")
|
||||
safe_print()
|
||||
|
||||
gateway = AIUIGateway(host=host, port=port)
|
||||
|
||||
@@ -139,32 +142,32 @@ async def main():
|
||||
reflection=False,
|
||||
)
|
||||
gateway.register_agent(agent, agent_id=agent_def["agent_id"])
|
||||
print(f" ✓ Gateway: {agent_def['icon']} {agent_def['name']} ({agent_def['model']})")
|
||||
safe_print(f" {icon('✓', '[OK]')} Gateway: {agent_def['name']} ({agent_def['model']})")
|
||||
|
||||
# Also register in dashboard CRUD
|
||||
_register_agents_in_dashboard()
|
||||
|
||||
print()
|
||||
print(f" Dashboard: http://localhost:{port}")
|
||||
print(f" Explorer: http://localhost:{port} → 🔬 Feature Explorer")
|
||||
print(f" WebSocket: ws://localhost:{port}/ws")
|
||||
print()
|
||||
safe_print()
|
||||
safe_print(f" Dashboard: http://localhost:{port}")
|
||||
safe_print(f" Explorer: http://localhost:{port} {icon('→', '->')} {icon('🔬', '')} Feature Explorer")
|
||||
safe_print(f" WebSocket: ws://localhost:{port}/ws")
|
||||
safe_print()
|
||||
|
||||
await gateway.start()
|
||||
else:
|
||||
# Standalone: REST APIs only, no agent execution
|
||||
print("📦 Starting in standalone mode (REST APIs only, no agent execution)")
|
||||
safe_print(f"{icon('📦', '[INFO]')} Starting in standalone mode (REST APIs only, no agent execution)")
|
||||
from praisonaiui.server import create_app
|
||||
import uvicorn
|
||||
|
||||
_register_agents_in_dashboard()
|
||||
|
||||
app = create_app()
|
||||
print()
|
||||
print(f" Dashboard: http://localhost:{port}")
|
||||
print(f" Explorer: http://localhost:{port} → 🔬 Feature Explorer")
|
||||
print(f" Note: POST endpoints may fail without gateway")
|
||||
print()
|
||||
safe_print()
|
||||
safe_print(f" Dashboard: http://localhost:{port}")
|
||||
safe_print(f" Explorer: http://localhost:{port} {icon('→', '->')} {icon('🔬', '')} Feature Explorer")
|
||||
safe_print(" Note: POST endpoints may fail without gateway")
|
||||
safe_print()
|
||||
|
||||
config = uvicorn.Config(app, host=host, port=port, log_level="info")
|
||||
server = uvicorn.Server(config)
|
||||
|
||||
@@ -25,9 +25,13 @@ Usage:
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
import praisonaiui as aiui
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from _shared.console import icon, safe_print
|
||||
|
||||
# ── Check gateway availability ──────────────────────────────
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
@@ -35,9 +39,9 @@ try:
|
||||
from praisonaiui.integration import AIUIGateway
|
||||
GATEWAY_OK = True
|
||||
except ImportError as e:
|
||||
print(f"⚠️ Gateway dependencies not found: {e}")
|
||||
print(" Install with: pip install praisonai praisonaiagents")
|
||||
print(" Falling back to standalone mode (REST APIs only).\n")
|
||||
safe_print(f"{icon('⚠️', '[WARN]')} Gateway dependencies not found: {e}")
|
||||
safe_print(" Install with: pip install praisonai praisonaiagents")
|
||||
safe_print(" Falling back to standalone mode (REST APIs only).\n")
|
||||
GATEWAY_OK = False
|
||||
|
||||
aiui.set_style("dashboard")
|
||||
@@ -127,7 +131,7 @@ def _register_agents_in_dashboard():
|
||||
"model": agent_def["model"],
|
||||
"icon": agent_def["icon"],
|
||||
})
|
||||
print(f" ✓ Agent: {agent_def['icon']} {agent_def['name']}")
|
||||
safe_print(f" {icon('✓', '[OK]')} Agent: {agent_def['name']}")
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────
|
||||
@@ -137,8 +141,8 @@ async def main():
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
|
||||
if GATEWAY_OK:
|
||||
print("🚀 Starting with Gateway mode (full execution + streaming)")
|
||||
print()
|
||||
safe_print(f"{icon('🚀', '[START]')} Starting with Gateway mode (full execution + streaming)")
|
||||
safe_print()
|
||||
|
||||
gateway = AIUIGateway(host=host, port=port)
|
||||
|
||||
@@ -151,21 +155,21 @@ async def main():
|
||||
reflection=False,
|
||||
)
|
||||
gateway.register_agent(agent, agent_id=agent_def["agent_id"])
|
||||
print(f" ✓ Gateway: {agent_def['icon']} {agent_def['name']} ({agent_def['model']})")
|
||||
safe_print(f" {icon('✓', '[OK]')} Gateway: {agent_def['name']} ({agent_def['model']})")
|
||||
|
||||
# Also register in dashboard CRUD
|
||||
_register_agents_in_dashboard()
|
||||
|
||||
print()
|
||||
print(f" Dashboard: http://localhost:{port}")
|
||||
print(f" Explorer: http://localhost:{port} → 🔬 Feature Explorer")
|
||||
print(f" WebSocket: ws://localhost:{port}/ws")
|
||||
print()
|
||||
safe_print()
|
||||
safe_print(f" Dashboard: http://localhost:{port}")
|
||||
safe_print(f" Explorer: http://localhost:{port} {icon('→', '->')} {icon('🔬', '')} Feature Explorer")
|
||||
safe_print(f" WebSocket: ws://localhost:{port}/ws")
|
||||
safe_print()
|
||||
|
||||
await gateway.start()
|
||||
else:
|
||||
# Standalone: REST APIs only, no agent execution
|
||||
print("📦 Starting in standalone mode (REST APIs only, no agent execution)")
|
||||
safe_print(f"{icon('📦', '[INFO]')} Starting in standalone mode (REST APIs only, no agent execution)")
|
||||
import uvicorn
|
||||
|
||||
from praisonaiui.server import create_app
|
||||
@@ -173,11 +177,11 @@ async def main():
|
||||
_register_agents_in_dashboard()
|
||||
|
||||
app = create_app()
|
||||
print()
|
||||
print(f" Dashboard: http://localhost:{port}")
|
||||
print(f" Explorer: http://localhost:{port} → 🔬 Feature Explorer")
|
||||
print(" Note: POST endpoints may fail without gateway")
|
||||
print()
|
||||
safe_print()
|
||||
safe_print(f" Dashboard: http://localhost:{port}")
|
||||
safe_print(f" Explorer: http://localhost:{port} {icon('→', '->')} {icon('🔬', '')} Feature Explorer")
|
||||
safe_print(" Note: POST endpoints may fail without gateway")
|
||||
safe_print()
|
||||
|
||||
config = uvicorn.Config(app, host=host, port=port, log_level="info")
|
||||
server = uvicorn.Server(config)
|
||||
|
||||
@@ -32,6 +32,9 @@ import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from _shared.console import icon, safe_print
|
||||
|
||||
# ── Check prerequisites ─────────────────────────────────────────────
|
||||
|
||||
try:
|
||||
@@ -40,8 +43,8 @@ try:
|
||||
SDK_OK = True
|
||||
except ImportError:
|
||||
SDK_OK = False
|
||||
print("⚠️ Missing dependencies. Install with:")
|
||||
print(" pip install praisonai[email] praisonaiagents")
|
||||
safe_print(f"{icon('⚠️', '[WARN]')} Missing dependencies. Install with:")
|
||||
safe_print(" pip install praisonai[email] praisonaiagents")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -68,8 +71,8 @@ agent = Agent(
|
||||
async def run_simple():
|
||||
"""Start email bot with automatic env var resolution."""
|
||||
bot = Bot("email", agent=agent)
|
||||
print(f"📧 Starting email bot for: {os.getenv('EMAIL_ADDRESS', '(not set)')}")
|
||||
print(" Listening for new emails... (Ctrl+C to stop)")
|
||||
safe_print(f"{icon('📧', '[EMAIL]')} Starting email bot for: {os.getenv('EMAIL_ADDRESS', '(not set)')}")
|
||||
safe_print(" Listening for new emails... (Ctrl+C to stop)")
|
||||
await bot.start()
|
||||
|
||||
|
||||
@@ -96,9 +99,9 @@ async def run_with_handlers():
|
||||
@bot.on_message
|
||||
async def log_email(message):
|
||||
subject = message.metadata.get("subject", "(no subject)")
|
||||
print(f"📨 From: {message.sender.username}")
|
||||
print(f" Subject: {subject}")
|
||||
print(f" Preview: {message.text[:100]}...")
|
||||
safe_print(f"{icon('📨', '[MSG]')} From: {message.sender.username}")
|
||||
safe_print(f" Subject: {subject}")
|
||||
safe_print(f" Preview: {message.text[:100]}...")
|
||||
|
||||
# Custom command: emails with subject starting with "/status"
|
||||
@bot.on_command("status")
|
||||
@@ -118,11 +121,11 @@ async def run_with_handlers():
|
||||
reply_to=message.message_id,
|
||||
)
|
||||
|
||||
print(f"📧 Starting email bot with custom handlers")
|
||||
print(f" Email: {bot._email_address}")
|
||||
print(f" Poll interval: {config.polling_interval}s")
|
||||
print(f" Allowed senders: {'all' if not config.allowed_users else config.allowed_users}")
|
||||
print(" Listening... (Ctrl+C to stop)")
|
||||
safe_print(f"{icon('📧', '[EMAIL]')} Starting email bot with custom handlers")
|
||||
safe_print(f" Email: {bot._email_address}")
|
||||
safe_print(f" Poll interval: {config.polling_interval}s")
|
||||
safe_print(f" Allowed senders: {'all' if not config.allowed_users else config.allowed_users}")
|
||||
safe_print(" Listening... (Ctrl+C to stop)")
|
||||
await bot.start()
|
||||
|
||||
|
||||
@@ -131,17 +134,17 @@ async def run_with_handlers():
|
||||
async def run_probe():
|
||||
"""Test email server connectivity."""
|
||||
bot = Bot("email", agent=agent)
|
||||
print("🔍 Testing email server connectivity...")
|
||||
safe_print(f"{icon('🔍', '[PROBE]')} Testing email server connectivity...")
|
||||
result = await bot.probe()
|
||||
|
||||
print(f"\n Connection: {'✅ OK' if result.ok else '❌ FAILED'}")
|
||||
print(f" Platform: {result.platform}")
|
||||
safe_print(f"\n Connection: {icon('✅', '[OK]') + ' OK' if result.ok else icon('❌', '[FAIL]') + ' FAILED'}")
|
||||
safe_print(f" Platform: {result.platform}")
|
||||
if result.bot_username:
|
||||
print(f" Email: {result.bot_username}")
|
||||
safe_print(f" Email: {result.bot_username}")
|
||||
if result.elapsed_ms:
|
||||
print(f" Latency: {result.elapsed_ms:.0f}ms")
|
||||
safe_print(f" Latency: {result.elapsed_ms:.0f}ms")
|
||||
if result.error:
|
||||
print(f" Error: {result.error}")
|
||||
safe_print(f" Error: {result.error}")
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -42,7 +42,9 @@ import sys
|
||||
# ── Imports ─────────────────────────────────────────────────────────
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "src"))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from _shared.console import icon, safe_print
|
||||
from praisonaiui.server import create_app
|
||||
import uvicorn
|
||||
|
||||
@@ -91,11 +93,11 @@ def seed_email_channel():
|
||||
})
|
||||
if r.status_code in (200, 201):
|
||||
channel_id = r.json().get("id", "unknown")
|
||||
print(f" ✅ Email channel registered: {channel_id}")
|
||||
safe_print(f" {icon('✅', '[OK]')} Email channel registered: {channel_id}")
|
||||
else:
|
||||
print(f" ⚠️ Channel registration: {r.status_code} — {r.json()}")
|
||||
safe_print(f" {icon('⚠️', '[WARN]')} Channel registration: {r.status_code} - {r.json()}")
|
||||
else:
|
||||
print(" ✓ Email channel already exists")
|
||||
safe_print(f" {icon('✓', '[OK]')} Email channel already exists")
|
||||
|
||||
# Also seed other channels for comparison in dashboard
|
||||
for name, platform in [("Discord #general", "discord"), ("Telegram Support", "telegram")]:
|
||||
@@ -106,7 +108,7 @@ def seed_email_channel():
|
||||
"platform": platform,
|
||||
"config": {},
|
||||
})
|
||||
print(f" ✓ Seeded {platform} channel")
|
||||
safe_print(f" {icon('✓', '[OK]')} Seeded {platform} channel")
|
||||
|
||||
|
||||
# ── Verify email platform support ──────────────────────────────────
|
||||
@@ -122,8 +124,8 @@ def verify_email_support():
|
||||
if r.status_code == 200:
|
||||
platforms = r.json().get("platforms", [])
|
||||
email_supported = "email" in platforms
|
||||
print(f" Supported platforms: {platforms}")
|
||||
print(f" Email supported: {'✅ Yes' if email_supported else '❌ No — add email to SUPPORTED_PLATFORMS'}")
|
||||
safe_print(f" Supported platforms: {platforms}")
|
||||
safe_print(f" Email supported: {icon('✅', '[OK]') + ' Yes' if email_supported else icon('❌', '[X]') + ' No - add email to SUPPORTED_PLATFORMS'}")
|
||||
return email_supported
|
||||
return False
|
||||
|
||||
@@ -137,51 +139,51 @@ def test_channel_api():
|
||||
app = create_app()
|
||||
client = TestClient(app)
|
||||
|
||||
print("\n📋 Channel API Test")
|
||||
print("=" * 40)
|
||||
safe_print(f"\n{icon('📋', '[TEST]')} Channel API Test")
|
||||
safe_print("=" * 40)
|
||||
|
||||
# List channels
|
||||
r = client.get("/api/channels")
|
||||
channels = r.json().get("channels", [])
|
||||
print(f" GET /api/channels: {len(channels)} channels")
|
||||
safe_print(f" GET /api/channels: {len(channels)} channels")
|
||||
for ch in channels:
|
||||
icon = "📧" if ch.get("platform") == "email" else "💬"
|
||||
print(f" {icon} {ch.get('name', '?')} ({ch.get('platform', '?')})")
|
||||
ch_icon = icon("📧", "[email]") if ch.get("platform") == "email" else icon("💬", "[chat]")
|
||||
safe_print(f" {ch_icon} {ch.get('name', '?')} ({ch.get('platform', '?')})")
|
||||
|
||||
# Get email channel status
|
||||
email_ch = next((ch for ch in channels if ch.get("platform") == "email"), None)
|
||||
if email_ch:
|
||||
ch_id = email_ch.get("id")
|
||||
r = client.get(f"/api/channels/{ch_id}/status")
|
||||
print(f" GET /api/channels/{ch_id}/status: {r.json()}")
|
||||
safe_print(f" GET /api/channels/{ch_id}/status: {r.json()}")
|
||||
|
||||
print()
|
||||
safe_print()
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("📧 PraisonAIUI — Email Channel Integration")
|
||||
print("=" * 50)
|
||||
print()
|
||||
safe_print(f"{icon('📧', '[EMAIL]')} PraisonAIUI - Email Channel Integration")
|
||||
safe_print("=" * 50)
|
||||
safe_print()
|
||||
|
||||
if "--test" in sys.argv:
|
||||
# Test mode: verify APIs + seed data
|
||||
print("🔍 Verifying email platform support...")
|
||||
safe_print(f"{icon('🔍', '[VERIFY]')} Verifying email platform support...")
|
||||
verify_email_support()
|
||||
print()
|
||||
print("🌱 Seeding email channel...")
|
||||
safe_print()
|
||||
safe_print(f"{icon('🌱', '[SEED]')} Seeding email channel...")
|
||||
seed_email_channel()
|
||||
test_channel_api()
|
||||
else:
|
||||
# Run the dashboard
|
||||
print("🌱 Seeding channels...")
|
||||
safe_print(f"{icon('🌱', '[SEED]')} Seeding channels...")
|
||||
seed_email_channel()
|
||||
print()
|
||||
safe_print()
|
||||
|
||||
app = create_app()
|
||||
print(f"✅ Dashboard at http://localhost:8084")
|
||||
print(f" Channels: http://localhost:8084/api/channels")
|
||||
print(f" Platforms: http://localhost:8084/api/channels/platforms")
|
||||
safe_print(f"{icon('✅', '[OK]')} Dashboard at http://localhost:8084")
|
||||
safe_print(" Channels: http://localhost:8084/api/channels")
|
||||
safe_print(" Platforms: http://localhost:8084/api/channels/platforms")
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
uvicorn.run(app, host=host, port=8084, log_level="info")
|
||||
@@ -33,6 +33,9 @@ import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from _shared.console import icon, safe_print
|
||||
|
||||
# ── Check prerequisites ─────────────────────────────────────────────
|
||||
|
||||
try:
|
||||
@@ -41,8 +44,8 @@ try:
|
||||
SDK_OK = True
|
||||
except ImportError:
|
||||
SDK_OK = False
|
||||
print("⚠️ Missing dependencies. Install with:")
|
||||
print(" pip install praisonai agentmail praisonaiagents")
|
||||
safe_print(f"{icon('⚠️', '[WARN]')} Missing dependencies. Install with:")
|
||||
safe_print(" pip install praisonai agentmail praisonaiagents")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -69,9 +72,9 @@ agent = Agent(
|
||||
async def run_simple():
|
||||
"""Start agentmail bot with automatic env var resolution."""
|
||||
bot = Bot("agentmail", agent=agent)
|
||||
print(f"📩 Starting AgentMail bot")
|
||||
print(f" API key: {'✅ set' if os.getenv('AGENTMAIL_API_KEY') else '❌ not set'}")
|
||||
print(" Listening for new emails... (Ctrl+C to stop)")
|
||||
safe_print(f"{icon('📩', '[MAIL]')} Starting AgentMail bot")
|
||||
safe_print(f" API key: {icon('✅', '[OK]') + ' set' if os.getenv('AGENTMAIL_API_KEY') else icon('❌', '[X]') + ' not set'}")
|
||||
safe_print(" Listening for new emails... (Ctrl+C to stop)")
|
||||
await bot.start()
|
||||
|
||||
|
||||
@@ -99,9 +102,9 @@ async def run_with_handlers():
|
||||
@bot.on_message
|
||||
async def log_email(message):
|
||||
subject = message.metadata.get("subject", "(no subject)")
|
||||
print(f"📨 From: {message.sender.username}")
|
||||
print(f" Subject: {subject}")
|
||||
print(f" Preview: {message.text[:100]}...")
|
||||
safe_print(f"{icon('📨', '[MSG]')} From: {message.sender.username}")
|
||||
safe_print(f" Subject: {subject}")
|
||||
safe_print(f" Preview: {message.text[:100]}...")
|
||||
|
||||
# Custom command: emails with subject starting with "/status"
|
||||
@bot.on_command("status")
|
||||
@@ -121,10 +124,10 @@ async def run_with_handlers():
|
||||
reply_to=message.message_id,
|
||||
)
|
||||
|
||||
print(f"📩 Starting AgentMail bot with custom handlers")
|
||||
print(f" Email: {bot.email_address or '(will be assigned on start)'}")
|
||||
print(f" Poll interval: {config.polling_interval}s")
|
||||
print(" Listening... (Ctrl+C to stop)")
|
||||
safe_print(f"{icon('📩', '[MAIL]')} Starting AgentMail bot with custom handlers")
|
||||
safe_print(f" Email: {bot.email_address or '(will be assigned on start)'}")
|
||||
safe_print(f" Poll interval: {config.polling_interval}s")
|
||||
safe_print(" Listening... (Ctrl+C to stop)")
|
||||
await bot.start()
|
||||
|
||||
|
||||
@@ -136,30 +139,30 @@ async def run_inbox_demo():
|
||||
token=os.environ.get("AGENTMAIL_API_KEY", ""),
|
||||
)
|
||||
|
||||
print("📩 AgentMail Inbox Lifecycle Demo")
|
||||
print("=" * 50)
|
||||
safe_print(f"{icon('📩', '[MAIL]')} AgentMail Inbox Lifecycle Demo")
|
||||
safe_print("=" * 50)
|
||||
|
||||
# Create inbox
|
||||
print("\n1. Creating inbox...")
|
||||
safe_print("\n1. Creating inbox...")
|
||||
inbox = await bot.create_inbox(domain=os.getenv("AGENTMAIL_DOMAIN"))
|
||||
print(f" ✅ Created: {inbox.get('email_address', 'unknown')}")
|
||||
print(f" ID: {inbox.get('id', 'unknown')}")
|
||||
safe_print(f" {icon('✅', '[OK]')} Created: {inbox.get('email_address', 'unknown')}")
|
||||
safe_print(f" ID: {inbox.get('id', 'unknown')}")
|
||||
|
||||
# List inboxes
|
||||
print("\n2. Listing inboxes...")
|
||||
safe_print("\n2. Listing inboxes...")
|
||||
inboxes = await bot.list_inboxes()
|
||||
print(f" Found: {len(inboxes)} inbox(es)")
|
||||
safe_print(f" Found: {len(inboxes)} inbox(es)")
|
||||
for ib in inboxes:
|
||||
print(f" 📧 {ib.get('email_address', '?')} (id: {ib.get('id', '?')})")
|
||||
safe_print(f" {icon('📧', '[EMAIL]')} {ib.get('email_address', '?')} (id: {ib.get('id', '?')})")
|
||||
|
||||
# Delete inbox
|
||||
inbox_id = inbox.get("id", "")
|
||||
if inbox_id:
|
||||
print(f"\n3. Deleting inbox {inbox_id}...")
|
||||
safe_print(f"\n3. Deleting inbox {inbox_id}...")
|
||||
deleted = await bot.delete_inbox(inbox_id)
|
||||
print(f" {'✅ Deleted' if deleted else '❌ Failed to delete'}")
|
||||
safe_print(f" {icon('✅', '[OK]') + ' Deleted' if deleted else icon('❌', '[X]') + ' Failed to delete'}")
|
||||
|
||||
print("\n✅ Inbox lifecycle complete")
|
||||
safe_print(f"\n{icon('✅', '[OK]')} Inbox lifecycle complete")
|
||||
|
||||
|
||||
# ── Probe: Test connectivity ────────────────────────────────────────
|
||||
@@ -167,17 +170,17 @@ async def run_inbox_demo():
|
||||
async def run_probe():
|
||||
"""Test AgentMail API connectivity."""
|
||||
bot = Bot("agentmail", agent=agent)
|
||||
print("🔍 Testing AgentMail API connectivity...")
|
||||
safe_print(f"{icon('🔍', '[PROBE]')} Testing AgentMail API connectivity...")
|
||||
result = await bot.probe()
|
||||
|
||||
print(f"\n Connection: {'✅ OK' if result.ok else '❌ FAILED'}")
|
||||
print(f" Platform: {result.platform}")
|
||||
safe_print(f"\n Connection: {icon('✅', '[OK]') + ' OK' if result.ok else icon('❌', '[FAIL]') + ' FAILED'}")
|
||||
safe_print(f" Platform: {result.platform}")
|
||||
if result.bot_username:
|
||||
print(f" Email: {result.bot_username}")
|
||||
safe_print(f" Email: {result.bot_username}")
|
||||
if result.elapsed_ms:
|
||||
print(f" Latency: {result.elapsed_ms:.0f}ms")
|
||||
safe_print(f" Latency: {result.elapsed_ms:.0f}ms")
|
||||
if result.error:
|
||||
print(f" Error: {result.error}")
|
||||
safe_print(f" Error: {result.error}")
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -40,7 +40,9 @@ import sys
|
||||
# ── Imports ─────────────────────────────────────────────────────────
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "src"))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from _shared.console import icon, safe_print
|
||||
from praisonaiui.server import create_app
|
||||
import uvicorn
|
||||
|
||||
@@ -88,11 +90,11 @@ def seed_agentmail_channel():
|
||||
})
|
||||
if r.status_code in (200, 201):
|
||||
channel_id = r.json().get("id", "unknown")
|
||||
print(f" ✅ AgentMail channel registered: {channel_id}")
|
||||
safe_print(f" {icon('✅', '[OK]')} AgentMail channel registered: {channel_id}")
|
||||
else:
|
||||
print(f" ⚠️ Channel registration: {r.status_code} — {r.json()}")
|
||||
safe_print(f" {icon('⚠️', '[WARN]')} Channel registration: {r.status_code} - {r.json()}")
|
||||
else:
|
||||
print(" ✓ AgentMail channel already exists")
|
||||
safe_print(f" {icon('✓', '[OK]')} AgentMail channel already exists")
|
||||
|
||||
# Also seed email + telegram for comparison in dashboard
|
||||
for name, platform in [("Support Email", "email"), ("Telegram Support", "telegram")]:
|
||||
@@ -103,7 +105,7 @@ def seed_agentmail_channel():
|
||||
"platform": platform,
|
||||
"config": {},
|
||||
})
|
||||
print(f" ✓ Seeded {platform} channel")
|
||||
safe_print(f" {icon('✓', '[OK]')} Seeded {platform} channel")
|
||||
|
||||
|
||||
# ── Verify agentmail platform support ──────────────────────────────
|
||||
@@ -120,9 +122,9 @@ def verify_agentmail_support():
|
||||
platforms = r.json().get("platforms", [])
|
||||
am_supported = "agentmail" in platforms
|
||||
email_supported = "email" in platforms
|
||||
print(f" Supported platforms: {platforms}")
|
||||
print(f" email supported: {'✅ Yes' if email_supported else '❌ No'}")
|
||||
print(f" agentmail supported: {'✅ Yes' if am_supported else '❌ No'}")
|
||||
safe_print(f" Supported platforms: {platforms}")
|
||||
safe_print(f" email supported: {icon('✅', '[OK]') + ' Yes' if email_supported else icon('❌', '[X]') + ' No'}")
|
||||
safe_print(f" agentmail supported: {icon('✅', '[OK]') + ' Yes' if am_supported else icon('❌', '[X]') + ' No'}")
|
||||
return am_supported
|
||||
return False
|
||||
|
||||
@@ -136,51 +138,51 @@ def test_channel_api():
|
||||
app = create_app()
|
||||
client = TestClient(app)
|
||||
|
||||
print("\n📋 Channel API Test")
|
||||
print("=" * 40)
|
||||
safe_print(f"\n{icon('📋', '[TEST]')} Channel API Test")
|
||||
safe_print("=" * 40)
|
||||
|
||||
# List channels
|
||||
r = client.get("/api/channels")
|
||||
channels = r.json().get("channels", [])
|
||||
print(f" GET /api/channels: {len(channels)} channels")
|
||||
safe_print(f" GET /api/channels: {len(channels)} channels")
|
||||
for ch in channels:
|
||||
icon = {"email": "📧", "agentmail": "📩"}.get(ch.get("platform", ""), "💬")
|
||||
print(f" {icon} {ch.get('name', '?')} ({ch.get('platform', '?')})")
|
||||
ch_icon = {"email": icon("📧", "[email]"), "agentmail": icon("📩", "[mail]")}.get(ch.get("platform", ""), icon("💬", "[chat]"))
|
||||
safe_print(f" {ch_icon} {ch.get('name', '?')} ({ch.get('platform', '?')})")
|
||||
|
||||
# Get agentmail channel status
|
||||
am_ch = next((ch for ch in channels if ch.get("platform") == "agentmail"), None)
|
||||
if am_ch:
|
||||
ch_id = am_ch.get("id")
|
||||
r = client.get(f"/api/channels/{ch_id}/status")
|
||||
print(f" GET /api/channels/{ch_id}/status: {r.json()}")
|
||||
safe_print(f" GET /api/channels/{ch_id}/status: {r.json()}")
|
||||
|
||||
print()
|
||||
safe_print()
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("📩 PraisonAIUI — AgentMail Channel Integration")
|
||||
print("=" * 50)
|
||||
print()
|
||||
safe_print(f"{icon('📩', '[MAIL]')} PraisonAIUI - AgentMail Channel Integration")
|
||||
safe_print("=" * 50)
|
||||
safe_print()
|
||||
|
||||
if "--test" in sys.argv:
|
||||
# Test mode: verify APIs + seed data
|
||||
print("🔍 Verifying agentmail platform support...")
|
||||
safe_print(f"{icon('🔍', '[VERIFY]')} Verifying agentmail platform support...")
|
||||
verify_agentmail_support()
|
||||
print()
|
||||
print("🌱 Seeding agentmail channel...")
|
||||
safe_print()
|
||||
safe_print(f"{icon('🌱', '[SEED]')} Seeding agentmail channel...")
|
||||
seed_agentmail_channel()
|
||||
test_channel_api()
|
||||
else:
|
||||
# Run the dashboard
|
||||
print("🌱 Seeding channels...")
|
||||
safe_print(f"{icon('🌱', '[SEED]')} Seeding channels...")
|
||||
seed_agentmail_channel()
|
||||
print()
|
||||
safe_print()
|
||||
|
||||
app = create_app()
|
||||
print(f"✅ Dashboard at http://localhost:8084")
|
||||
print(f" Channels: http://localhost:8084/api/channels")
|
||||
print(f" Platforms: http://localhost:8084/api/channels/platforms")
|
||||
safe_print(f"{icon('✅', '[OK]')} Dashboard at http://localhost:8084")
|
||||
safe_print(" Channels: http://localhost:8084/api/channels")
|
||||
safe_print(" Platforms: http://localhost:8084/api/channels/platforms")
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
uvicorn.run(app, host=host, port=8084, log_level="info")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Cross-platform console output for examples.
|
||||
|
||||
Legacy Windows consoles default stdout to a non-UTF code page (cp1252/cp437)
|
||||
that cannot encode emoji such as U+1F680. Calling ``print`` with those
|
||||
characters raises ``UnicodeEncodeError`` and crashes the example before the
|
||||
server starts. These helpers mirror the ``_supports_unicode``/``_icon`` pattern
|
||||
in ``praisonaiui.cli`` so examples degrade to ASCII instead of crashing while
|
||||
preserving emoji on UTF-aware terminals.
|
||||
|
||||
Usage:
|
||||
from _shared.console import safe_print, icon
|
||||
|
||||
safe_print(f"{icon('🚀', '[START]')} Starting gateway...")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
def supports_unicode() -> bool:
|
||||
try:
|
||||
encoding = sys.stdout.encoding or ""
|
||||
except Exception:
|
||||
return False
|
||||
return "utf" in encoding.lower()
|
||||
|
||||
|
||||
def icon(symbol: str, fallback: str) -> str:
|
||||
return symbol if supports_unicode() else fallback
|
||||
|
||||
|
||||
def safe_print(*args: Any, **kwargs: Any) -> None:
|
||||
sep = kwargs.pop("sep", " ")
|
||||
text = sep.join(str(a) for a in args)
|
||||
try:
|
||||
print(text, **kwargs)
|
||||
except UnicodeEncodeError:
|
||||
print(text.encode("ascii", errors="replace").decode("ascii"), **kwargs)
|
||||
@@ -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,51 @@
|
||||
# Voice Agent — local secrets. Never commit.
|
||||
|
||||
OPENAI_API_KEY=
|
||||
|
||||
VOICE_AGENT_PORT=8001
|
||||
|
||||
# LLM for voice assistant (synced to provider on setup / start_dev)
|
||||
VOICE_AGENT_MODEL=gpt-4o-mini
|
||||
|
||||
# Voice telephony provider
|
||||
VOICE_API_BASE=
|
||||
VOICE_PRIVATE_API_KEY=
|
||||
VOICE_PUBLIC_API_KEY=
|
||||
VOICE_SERVER_URL_SECRET=
|
||||
VOICE_ASSISTANT_ID=
|
||||
VOICE_PHONE_NUMBER_ID=
|
||||
|
||||
# Phase 2 — browser web talk (provider web SDK)
|
||||
# VOICE_WEB_SDK_URL=https://esm.sh/@vapi-ai/[email protected]
|
||||
# VOICE_WEB_SDK_GLOBAL=VoiceWebClient
|
||||
|
||||
# Dynamic assistant (assistant-request returns inline config + agent tools)
|
||||
# VOICE_DYNAMIC_ASSISTANT=true
|
||||
# VOICE_AGENT_EXTRA_TOOLS=meeting_search
|
||||
|
||||
# ElevenLabs Speech Engine — OpenAI tool calling on the sidecar LLM path
|
||||
# VOICE_SPEECH_USE_TOOLS=true
|
||||
|
||||
# Cross-call memory — stable browser user id → PraisonAI session reload
|
||||
# VOICE_MEMORY_ENABLED=true
|
||||
# VOICE_MEMORY_HISTORY_LIMIT=20
|
||||
# VOICE_MEMORY_USE_KNOWLEDGE=true
|
||||
|
||||
# OpenAI Realtime (browser speech-to-speech — higher audio token cost; keep demos short)
|
||||
# REALTIME_MODEL=gpt-realtime-2.1-mini
|
||||
# REALTIME_VOICE=marin
|
||||
# REALTIME_REASONING_EFFORT=low
|
||||
# REALTIME_INSTRUCTIONS=You are a helpful voice assistant. Keep replies to one or two short sentences.
|
||||
# REALTIME_FIRST_MESSAGE=
|
||||
|
||||
# ElevenLabs Speech Engine (Phase 3 — browser voice, BYO LLM)
|
||||
# ELEVENLABS_API_KEY=
|
||||
# SPEECH_ENGINE_ID= # auto-written by setup_speech_engine.py
|
||||
# SPEECH_ENGINE_PUBLIC_URL= # auto-written — dedicated tunnel for sidecar :8002
|
||||
# SPEECH_ENGINE_PORT=8002
|
||||
# SPEECH_ENGINE_FIRST_MESSAGE=Hello! How can I help you today?
|
||||
# ELEVENLABS_VOICE_ID= # Praison/custom voice from ElevenLabs dashboard
|
||||
|
||||
# Public HTTPS URL for provider webhook (Cloudflare tunnel)
|
||||
# cloudflared tunnel --url http://127.0.0.1:8001
|
||||
PUBLIC_API_BASE_URL=
|
||||
@@ -0,0 +1,118 @@
|
||||
# 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 |
|
||||
| `/api/voice/calls/{id}/live-transcript` | GET | SSE live transcript stream |
|
||||
| `/api/voice/web-config` | GET | Browser talk widget config |
|
||||
| `/webhooks/voice` | POST | Provider webhooks (tool-calls, transcript, end-of-call) |
|
||||
| `/api/voice/config` | GET | Config status |
|
||||
| `/api/voice/doctor` | GET | Voice stack health checks (.env, ports, APIs, tunnel) |
|
||||
|
||||
## Phase 2 features
|
||||
|
||||
- **Live transcript** — `GET /api/voice/calls/{id}/live-transcript` (SSE); **Call detail** page uses `plugin.js` + EventSource
|
||||
- **Dynamic assistant** — set `VOICE_DYNAMIC_ASSISTANT=true` so `assistant-request` returns inline config with agent tool names
|
||||
- **Agent tools** — `tool-calls` run through `integrations/voice/agent_runner.py` (same registry as dashboard chat)
|
||||
- **Web talk** — **Web talk** page loads provider web SDK from `VOICE_WEB_SDK_URL` / `VOICE_WEB_SDK_GLOBAL`
|
||||
- **Chat bridge** — final transcript lines and end-of-call summaries appear in **Chat** sidebar as `📞 Call …` sessions
|
||||
|
||||
Optional: `VOICE_AGENT_EXTRA_TOOLS=meeting_search` adds meeting search when `praisonai_tools` is installed.
|
||||
|
||||
## Cross-call memory (PraisonAI sessions)
|
||||
|
||||
Stable browser `voice-user-id` (localStorage) maps to `voice-user-{id}` PraisonAI session folder. Prior turns reload on new calls/restarts when `VOICE_MEMORY_ENABLED=true` (default).
|
||||
|
||||
| Route | Purpose |
|
||||
|-------|---------|
|
||||
| `/api/voice/memory/attach-user` | ElevenLabs — attach user before conversation starts |
|
||||
| `/api/voice/memory/bind` | Bind `call_id` + `user_id` |
|
||||
| `/api/voice/memory/resolve?user_id=` | Resolve Praison session id |
|
||||
|
||||
Knowledge base entries are injected into voice prompts when available.
|
||||
|
||||
## Call detail UI (PraisonAIUI components)
|
||||
|
||||
Call detail uses server-built components (`code_block`, `badge`, `key_value_list`, `tabs`) via `GET /api/voice/calls/{id}/ui`. Live SSE updates the transcript code block. Mic/WebRTC talk pages still use minimal `plugin.js`.
|
||||
|
||||
## End-of-call summary & analytics
|
||||
|
||||
When a session ends (ElevenLabs, GPT Realtime, or telephony webhook), `integrations/voice/call_finalize.py`:
|
||||
|
||||
- Generates a one-sentence summary (OpenAI) when the provider did not send one
|
||||
- Stores **duration** and **turn count** in call metadata
|
||||
- Posts the summary to the **Chat** sidebar via the chat bridge
|
||||
|
||||
**Voice calls** table shows Duration and Turns. **Config** page runs the voice doctor (`GET /api/voice/doctor` or `py -3.13 doctor.py`).
|
||||
|
||||
## MCP manager
|
||||
|
||||
The dashboard **MCP** page lists configured Model Context Protocol servers (`GET /api/mcp/servers`) and supports connect/disconnect from the UI.
|
||||
|
||||
## Phase 3 — ElevenLabs Speech Engine
|
||||
|
||||
Bring-your-own-LLM browser voice ([Speech Engine docs](https://elevenlabs.io/docs/overview/capabilities/speech-engine)):
|
||||
|
||||
```powershell
|
||||
pip install -r requirements-voice.txt
|
||||
# Add ELEVENLABS_API_KEY to .env
|
||||
.\start_dev.ps1 -Restart
|
||||
```
|
||||
|
||||
- **ElevenLabs talk** page — WebRTC client, token from `/api/voice/speech-engine/token`
|
||||
- **Speech Engine sidecar** (`speech_engine_sidecar.py` on port **8002**) — official SDK WebSocket server
|
||||
- **Dedicated tunnel** — `SPEECH_ENGINE_PUBLIC_URL` → `wss://…/ws` (separate from dashboard tunnel)
|
||||
- **`setup_speech_engine.py`** — registers the public WebSocket URL with ElevenLabs
|
||||
|
||||
| Route | Purpose |
|
||||
|-------|---------|
|
||||
| `/api/voice/speech-engine/config` | Client config |
|
||||
| `/api/voice/speech-engine/token` | Browser conversation token |
|
||||
| `/ws` | Speech Engine WebSocket (ElevenLabs → your server) |
|
||||
|
||||
## Architecture
|
||||
|
||||
External platform handles voice pipeline (phone, STT, TTS, turn-taking). This app handles:
|
||||
|
||||
- **tool-calls** → PraisonAI agent tools (`integrations/voice/agent_runner.py`)
|
||||
- **transcript** / **end-of-call-report** → SQLite store, SSE hub, chat bridge
|
||||
- **assistant-request** → default `VOICE_ASSISTANT_ID` or dynamic inline assistant
|
||||
|
||||
Same pattern as `examples/python/meeting-agent` (Recall webhooks).
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
pytest tests/unit/test_voice_agent.py -v
|
||||
```
|
||||
@@ -0,0 +1,285 @@
|
||||
"""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 + live call detail + web talk + chat bridge
|
||||
|
||||
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
|
||||
|
||||
import praisonaiui as aiui
|
||||
from praisonaiui.server import create_app
|
||||
from starlette.routing import Route, WebSocketRoute
|
||||
|
||||
from memory_routes import (
|
||||
api_voice_memory_attach_user,
|
||||
api_voice_memory_bind,
|
||||
api_voice_memory_config,
|
||||
api_voice_memory_for_call,
|
||||
api_voice_memory_resolve,
|
||||
)
|
||||
from realtime_routes import (
|
||||
api_realtime_config,
|
||||
api_realtime_end,
|
||||
api_realtime_new_call,
|
||||
api_realtime_session,
|
||||
api_realtime_transcript,
|
||||
)
|
||||
from speech_engine_routes import (
|
||||
api_speech_engine_config,
|
||||
api_speech_engine_token,
|
||||
speech_engine_websocket,
|
||||
)
|
||||
from voice_routes import (
|
||||
api_voice_call_detail,
|
||||
api_voice_call_detail_ui,
|
||||
api_voice_config_status,
|
||||
api_voice_create_call,
|
||||
api_voice_doctor,
|
||||
api_voice_list_calls,
|
||||
api_voice_live_transcript,
|
||||
api_voice_web_config,
|
||||
webhook_voice,
|
||||
)
|
||||
|
||||
_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:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
_load_local_env()
|
||||
|
||||
aiui.set_pages(
|
||||
[
|
||||
"chat",
|
||||
"voice-calls",
|
||||
"call-detail",
|
||||
"web-talk",
|
||||
"eleven-talk",
|
||||
"realtime-talk",
|
||||
"mcp",
|
||||
"config",
|
||||
]
|
||||
)
|
||||
aiui.set_style("dashboard")
|
||||
aiui.set_custom_js(_EXAMPLE_DIR / "plugin.js")
|
||||
|
||||
|
||||
def get_agent():
|
||||
from integrations.voice.agent_runner import get_voice_agent
|
||||
|
||||
return get_voice_agent()
|
||||
|
||||
|
||||
aiui.register_agent("voice-assistant", get_agent())
|
||||
|
||||
|
||||
@aiui.on_app_startup
|
||||
async def _start_voice_chat_bridge() -> None:
|
||||
from integrations.voice.chat_bridge import start_chat_bridge_worker
|
||||
|
||||
start_chat_bridge_worker()
|
||||
|
||||
|
||||
@aiui.reply
|
||||
async def on_message(message: str):
|
||||
"""Chat with the voice demo agent."""
|
||||
from integrations.voice.tools import demo_tool_reply
|
||||
|
||||
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.call_finalize import analytics_for_record
|
||||
from integrations.voice.store import VoiceCallStore
|
||||
|
||||
rows = VoiceCallStore.list_calls()
|
||||
out: list[dict[str, Any]] = []
|
||||
for r in rows:
|
||||
analytics = analytics_for_record(r)
|
||||
out.append(
|
||||
{
|
||||
"id": r["call_id"],
|
||||
"status": r["status"],
|
||||
"customer_number": r.get("customer_number") or "—",
|
||||
"transcript_preview": (r.get("transcript") or "")[:120],
|
||||
"duration": analytics.get("duration") or "—",
|
||||
"turn_count": analytics.get("turn_count") or 0,
|
||||
"updated_at": r.get("updated_at") or "",
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
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["duration"], str(c["turn_count"]), 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", "Duration", "Turns", "Updated"],
|
||||
rows=rows or [["—", "—", "—", "—", "—", "—"]],
|
||||
),
|
||||
aiui.alert(
|
||||
"Open **Call detail** for live transcript (SSE). "
|
||||
"Voice sessions also appear in **Chat** sidebar as 📞 entries.",
|
||||
variant="info",
|
||||
title="Phase 2",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@aiui.page("call-detail", title="Call detail", icon="🎙️", group="Voice", order=2)
|
||||
async def call_detail_page():
|
||||
"""Server fallback — plugin.js registerView overrides with live SSE + UI components."""
|
||||
from integrations.voice.call_detail_ui import build_call_detail_layout
|
||||
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
|
||||
if record:
|
||||
return build_call_detail_layout(record)
|
||||
return aiui.layout(
|
||||
[
|
||||
aiui.alert(
|
||||
"No calls yet. Start from ElevenLabs talk, GPT Realtime, or Web talk.",
|
||||
variant="info",
|
||||
title="Call detail",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@aiui.page("web-talk", title="Web talk", icon="🎤", group="Voice", order=3)
|
||||
async def web_talk_page():
|
||||
return aiui.layout(
|
||||
[
|
||||
aiui.text("Browser voice widget — client view loads from plugin.js"),
|
||||
aiui.alert(
|
||||
"Set VOICE_PUBLIC_API_KEY, VOICE_ASSISTANT_ID, VOICE_WEB_SDK_URL, and "
|
||||
"VOICE_WEB_SDK_GLOBAL in .env.",
|
||||
variant="info",
|
||||
title="Web SDK",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@aiui.page("eleven-talk", title="ElevenLabs talk", icon="🗣️", group="Voice", order=4)
|
||||
async def eleven_talk_page():
|
||||
return aiui.layout(
|
||||
[
|
||||
aiui.text("Browser voice via ElevenLabs Speech Engine — loads from plugin.js"),
|
||||
aiui.alert(
|
||||
"Set ELEVENLABS_API_KEY, run setup_speech_engine.py after start_dev.ps1, "
|
||||
"then click Start conversation.",
|
||||
variant="info",
|
||||
title="Speech Engine",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@aiui.page("realtime-talk", title="GPT Realtime", icon="⚡", group="Voice", order=5)
|
||||
async def realtime_talk_page():
|
||||
return aiui.layout(
|
||||
[
|
||||
aiui.text("Browser voice via OpenAI gpt-realtime-2.1-mini — loads from plugin.js"),
|
||||
aiui.alert(
|
||||
"Set OPENAI_API_KEY and REALTIME_MODEL=gpt-realtime-2.1-mini.",
|
||||
variant="warning",
|
||||
title="OpenAI Realtime",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
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/calls/{call_id}/ui", api_voice_call_detail_ui, methods=["GET"]),
|
||||
Route("/api/voice/calls/{call_id}/live-transcript", api_voice_live_transcript, methods=["GET"]),
|
||||
Route("/api/voice/config", api_voice_config_status, methods=["GET"]),
|
||||
Route("/api/voice/doctor", api_voice_doctor, methods=["GET"]),
|
||||
Route("/api/voice/web-config", api_voice_web_config, methods=["GET"]),
|
||||
Route("/api/voice/speech-engine/config", api_speech_engine_config, methods=["GET"]),
|
||||
Route("/api/voice/speech-engine/token", api_speech_engine_token, methods=["GET"]),
|
||||
Route("/api/voice/memory/config", api_voice_memory_config, methods=["GET"]),
|
||||
Route("/api/voice/memory/bind", api_voice_memory_bind, methods=["POST"]),
|
||||
Route("/api/voice/memory/attach-user", api_voice_memory_attach_user, methods=["POST"]),
|
||||
Route("/api/voice/memory/resolve", api_voice_memory_resolve, methods=["GET"]),
|
||||
Route("/api/voice/memory/for-call", api_voice_memory_for_call, methods=["GET"]),
|
||||
Route("/api/voice/realtime/config", api_realtime_config, methods=["GET"]),
|
||||
Route("/api/voice/realtime/new-call", api_realtime_new_call, methods=["POST"]),
|
||||
Route("/api/voice/realtime/session", api_realtime_session, methods=["POST"]),
|
||||
Route("/api/voice/realtime/transcript", api_realtime_transcript, methods=["POST"]),
|
||||
Route("/api/voice/realtime/end", api_realtime_end, methods=["POST"]),
|
||||
Route("/webhooks/voice", webhook_voice, methods=["POST"]),
|
||||
WebSocketRoute("/ws", speech_engine_websocket),
|
||||
]
|
||||
|
||||
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,41 @@
|
||||
"""CLI for voice-agent stack diagnostics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
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_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:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def main() -> int:
|
||||
_load_local_env()
|
||||
from integrations.voice.doctor import run_voice_doctor
|
||||
|
||||
app_port = int(os.getenv("VOICE_AGENT_PORT", "8001"))
|
||||
sidecar_port = int(os.getenv("SPEECH_ENGINE_SIDECAR_PORT", "8002"))
|
||||
report = run_voice_doctor(app_port=app_port, sidecar_port=sidecar_port)
|
||||
print(json.dumps(report, indent=2))
|
||||
return 1 if report["summary"]["failed"] else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1 @@
|
||||
"""Voice agent integrations."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Voice telephony provider integration."""
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Run voice tool-calls through the PraisonAI agent tool registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Callable
|
||||
|
||||
from integrations.voice.tools import execute_tool, register_tool
|
||||
|
||||
_agent: Any | None = None
|
||||
_session_agents: dict[str, Any] = {}
|
||||
|
||||
|
||||
def _tool_name(tool: Any) -> str | None:
|
||||
for attr in ("name", "__name__"):
|
||||
value = getattr(tool, attr, None)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
fn = getattr(tool, "func", None) or getattr(tool, "function", None)
|
||||
if fn is not None:
|
||||
return _tool_name(fn)
|
||||
return None
|
||||
|
||||
|
||||
def _build_voice_agent(*, session_id: str | None = None):
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.config.feature_configs import MemoryConfig
|
||||
from praisonaiagents.escalation.loop_guard import LoopGuard, LoopGuardConfig
|
||||
|
||||
from integrations.voice.session_memory import memory_enabled
|
||||
from integrations.voice.tools import echo_message, get_current_time
|
||||
|
||||
register_tool("get_current_time", get_current_time)
|
||||
register_tool("echo_message", echo_message)
|
||||
|
||||
tools: list[Any] = [get_current_time, echo_message]
|
||||
extra = os.getenv("VOICE_AGENT_EXTRA_TOOLS", "").strip()
|
||||
if extra == "meeting_search":
|
||||
try:
|
||||
from praisonai_tools.tools.meeting_tools import search_meetings
|
||||
|
||||
tools.append(search_meetings)
|
||||
register_tool("search_meetings", search_meetings)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
memory_cfg = None
|
||||
if memory_enabled() and session_id:
|
||||
memory_cfg = MemoryConfig(
|
||||
history=True,
|
||||
session_id=session_id,
|
||||
history_limit=int(os.getenv("VOICE_MEMORY_HISTORY_LIMIT", "20")),
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
name="Voice Assistant",
|
||||
instructions=(
|
||||
"You help with voice and phone agent demos. "
|
||||
"Use tools when the caller asks for the time, wants a message echoed, "
|
||||
"or asks about past meetings."
|
||||
),
|
||||
model=os.getenv("VOICE_AGENT_MODEL", "gpt-4o-mini"),
|
||||
tools=tools,
|
||||
memory=memory_cfg or False,
|
||||
)
|
||||
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))
|
||||
|
||||
for tool in tools:
|
||||
name = _tool_name(tool)
|
||||
if name:
|
||||
register_tool(name, _callable_tool(tool))
|
||||
return agent
|
||||
|
||||
|
||||
def get_voice_agent(session_id: str | None = None):
|
||||
"""Lazy-load the voice assistant (optionally bound to a PraisonAI session)."""
|
||||
global _agent
|
||||
if session_id:
|
||||
cached = _session_agents.get(session_id)
|
||||
if cached is not None:
|
||||
return cached
|
||||
agent = _build_voice_agent(session_id=session_id)
|
||||
_session_agents[session_id] = agent
|
||||
return agent
|
||||
if _agent is not None:
|
||||
return _agent
|
||||
_agent = _build_voice_agent()
|
||||
return _agent
|
||||
|
||||
|
||||
def _callable_tool(tool: Any) -> Callable[..., Any]:
|
||||
fn = getattr(tool, "func", None) or getattr(tool, "run", None) or tool
|
||||
return fn # type: ignore[return-value]
|
||||
|
||||
|
||||
def execute_voice_tool(
|
||||
name: str,
|
||||
parameters: dict[str, Any] | None,
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
) -> str:
|
||||
"""Execute via agent tool registry, then fall back to static registry."""
|
||||
params = parameters or {}
|
||||
agent = get_voice_agent(session_id)
|
||||
for tool in agent.tools or []:
|
||||
tname = _tool_name(tool)
|
||||
if tname != name:
|
||||
continue
|
||||
fn = _callable_tool(tool)
|
||||
try:
|
||||
result = fn(**params)
|
||||
except TypeError:
|
||||
try:
|
||||
result = fn(params)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return json.dumps({"error": str(exc)})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return json.dumps({"error": str(exc)})
|
||||
if isinstance(result, str):
|
||||
return result
|
||||
return json.dumps(result)
|
||||
return execute_tool(name, params)
|
||||
|
||||
|
||||
def list_registered_tool_names() -> list[str]:
|
||||
agent = get_voice_agent()
|
||||
names: list[str] = []
|
||||
for tool in agent.tools or []:
|
||||
tname = _tool_name(tool)
|
||||
if tname:
|
||||
names.append(tname)
|
||||
return names
|
||||
|
||||
|
||||
def build_assistant_request_response(settings: Any) -> dict[str, Any]:
|
||||
"""Return provider assistant config for dynamic assistant-request events."""
|
||||
if settings.default_assistant_id and not os.getenv("VOICE_DYNAMIC_ASSISTANT", "").lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
):
|
||||
return {"assistantId": settings.default_assistant_id}
|
||||
|
||||
tool_names = list_registered_tool_names()
|
||||
assistant: dict[str, Any] = {
|
||||
"name": "Praison Voice Assistant",
|
||||
"firstMessage": os.getenv("VOICE_FIRST_MESSAGE", "Hello! How can I help you today?"),
|
||||
"model": {
|
||||
"provider": os.getenv("VOICE_MODEL_PROVIDER", "openai"),
|
||||
"model": os.getenv("VOICE_AGENT_MODEL", "gpt-4o-mini"),
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are a helpful voice assistant. When asked for the time, call get_current_time. "
|
||||
"When asked to repeat or echo text, call echo_message with the message parameter. "
|
||||
"Available tools: "
|
||||
+ ", ".join(tool_names or ["get_current_time", "echo_message"])
|
||||
),
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
if settings.webhook_url and settings.server_url_secret:
|
||||
assistant["server"] = {
|
||||
"url": settings.webhook_url,
|
||||
"headers": {"X-Voice-Webhook-Secret": settings.server_url_secret},
|
||||
}
|
||||
return {"assistant": assistant}
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Build PraisonAIUI components for the call detail page."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import praisonaiui as aiui
|
||||
|
||||
|
||||
def build_call_detail_layout(record: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return dashboard component tree for call detail (code_block + metadata)."""
|
||||
from integrations.voice.call_finalize import analytics_for_record
|
||||
|
||||
metadata = record.get("metadata") or {}
|
||||
analytics = analytics_for_record(record)
|
||||
transcript = (record.get("transcript") or "").strip() or "(empty — waiting for speech…)"
|
||||
summary = (record.get("summary") or "").strip() or "(not available yet)"
|
||||
status = str(record.get("status") or "unknown")
|
||||
|
||||
kv_items: list[dict[str, str]] = [
|
||||
{"label": "Call ID", "value": str(record.get("call_id") or "—")},
|
||||
{"label": "Status", "value": status},
|
||||
{"label": "Customer", "value": str(record.get("customer_number") or "—")},
|
||||
]
|
||||
if metadata.get("user_id"):
|
||||
kv_items.append({"label": "User ID", "value": str(metadata["user_id"])})
|
||||
if metadata.get("praison_session_id"):
|
||||
kv_items.append({"label": "Memory session", "value": str(metadata["praison_session_id"])})
|
||||
kv_items.append({"label": "Duration", "value": str(analytics.get("duration") or "—")})
|
||||
kv_items.append({"label": "Turns", "value": str(analytics.get("turn_count") or 0)})
|
||||
|
||||
return aiui.layout(
|
||||
[
|
||||
aiui.text("Live voice call transcript and metadata"),
|
||||
aiui.badge(status, variant="default"),
|
||||
aiui.definition_list(kv_items, title="Call metadata"),
|
||||
aiui.alert(
|
||||
"Transcript updates live via SSE. Final lines use PraisonAIUI code blocks.",
|
||||
variant="info",
|
||||
title="Live feed",
|
||||
),
|
||||
aiui.tabs(
|
||||
[
|
||||
{
|
||||
"label": "Transcript",
|
||||
"children": [
|
||||
aiui.code_block(transcript, language="text"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"label": "Summary",
|
||||
"children": [aiui.text(summary)],
|
||||
},
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,126 @@
|
||||
"""End-of-call summary, analytics, and chat bridge on session close."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def count_turns(transcript: str) -> int:
|
||||
"""Count user/agent lines in stored transcript."""
|
||||
turns = 0
|
||||
for line in (transcript or "").splitlines():
|
||||
text = line.strip()
|
||||
if text.startswith("user:") or text.startswith("agent:") or text.startswith("assistant:"):
|
||||
turns += 1
|
||||
return turns
|
||||
|
||||
|
||||
def duration_seconds(created_at: str | None, ended_at: str | None) -> int | None:
|
||||
if not created_at or not ended_at:
|
||||
return None
|
||||
try:
|
||||
start = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
|
||||
end = datetime.fromisoformat(ended_at.replace("Z", "+00:00"))
|
||||
return max(0, int((end - start).total_seconds()))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def format_duration(seconds: int | None) -> str:
|
||||
if seconds is None:
|
||||
return "—"
|
||||
if seconds < 60:
|
||||
return f"{seconds}s"
|
||||
minutes, rem = divmod(seconds, 60)
|
||||
return f"{minutes}m {rem}s"
|
||||
|
||||
|
||||
async def generate_call_summary(transcript: str) -> str:
|
||||
"""One-sentence LLM summary for call detail + chat sidebar."""
|
||||
text = (transcript or "").strip()
|
||||
if not text:
|
||||
return "Empty call — no conversation captured."
|
||||
api_key = os.getenv("OPENAI_API_KEY", "").strip()
|
||||
if not api_key:
|
||||
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
|
||||
preview = " · ".join(lines[:3])
|
||||
return f"Call covered: {preview[:240]}"
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
client = AsyncOpenAI(api_key=api_key)
|
||||
try:
|
||||
response = await asyncio.wait_for(
|
||||
client.chat.completions.create(
|
||||
model=os.getenv("VOICE_AGENT_MODEL", "gpt-4o-mini"),
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Summarize this voice call in one concise sentence for a dashboard. "
|
||||
"Mention main topic and outcome only."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": text[:6000]},
|
||||
],
|
||||
max_tokens=80,
|
||||
),
|
||||
timeout=15.0,
|
||||
)
|
||||
summary = (response.choices[0].message.content or "").strip()
|
||||
return summary or "Call completed."
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("Call summary generation failed")
|
||||
return "Call completed — summary unavailable."
|
||||
|
||||
|
||||
async def finalize_call(call_id: str, *, status: str) -> None:
|
||||
"""Persist analytics, optional summary, and notify chat bridge."""
|
||||
from integrations.voice.chat_bridge import enqueue_call_summary
|
||||
from integrations.voice.store import VoiceCallStore
|
||||
|
||||
record = VoiceCallStore.get_call(call_id)
|
||||
if not record:
|
||||
return
|
||||
|
||||
transcript = (record.get("transcript") or "").strip()
|
||||
metadata: dict[str, Any] = dict(record.get("metadata") or {})
|
||||
ended_at = datetime.now(timezone.utc).isoformat()
|
||||
metadata["ended_at"] = ended_at
|
||||
metadata["turn_count"] = count_turns(transcript)
|
||||
metadata["duration_sec"] = duration_seconds(record.get("created_at"), ended_at)
|
||||
|
||||
summary = (record.get("summary") or "").strip()
|
||||
if not summary and transcript:
|
||||
summary = await generate_call_summary(transcript)
|
||||
|
||||
VoiceCallStore.upsert_call(
|
||||
call_id,
|
||||
status=status,
|
||||
summary=summary or None,
|
||||
metadata=metadata,
|
||||
)
|
||||
enqueue_call_summary(call_id, summary or "(no summary)", status)
|
||||
|
||||
|
||||
def analytics_for_record(record: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Derived analytics for API + dashboard tables."""
|
||||
metadata = record.get("metadata") or {}
|
||||
transcript = record.get("transcript") or ""
|
||||
turns = metadata.get("turn_count")
|
||||
if turns is None:
|
||||
turns = count_turns(transcript)
|
||||
duration_sec = metadata.get("duration_sec")
|
||||
if duration_sec is None:
|
||||
duration_sec = duration_seconds(record.get("created_at"), metadata.get("ended_at"))
|
||||
return {
|
||||
"turn_count": turns,
|
||||
"duration_sec": duration_sec,
|
||||
"duration": format_duration(duration_sec if isinstance(duration_sec, int) else None),
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Bridge voice call events into Chat UI sessions (sidebar + history)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_bridge_queue: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=512)
|
||||
_worker_lock = threading.Lock()
|
||||
_worker_task: asyncio.Task[None] | None = None
|
||||
|
||||
|
||||
def voice_session_id(call_id: str) -> str:
|
||||
return f"voice-{call_id}"
|
||||
|
||||
|
||||
def enqueue_transcript(call_id: str, role: str, text: str, *, final: bool) -> None:
|
||||
if not call_id or not text.strip():
|
||||
return
|
||||
_enqueue(
|
||||
{
|
||||
"kind": "transcript",
|
||||
"call_id": call_id,
|
||||
"role": role,
|
||||
"text": text.strip(),
|
||||
"final": final,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def enqueue_call_summary(call_id: str, summary: str, status: str) -> None:
|
||||
if not call_id:
|
||||
return
|
||||
_enqueue(
|
||||
{
|
||||
"kind": "summary",
|
||||
"call_id": call_id,
|
||||
"summary": summary.strip(),
|
||||
"status": status,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _enqueue(item: dict[str, Any]) -> None:
|
||||
try:
|
||||
_bridge_queue.put_nowait(item)
|
||||
except queue.Full:
|
||||
logger.warning("Voice chat bridge queue full; dropping event")
|
||||
|
||||
|
||||
async def _ensure_session(call_id: str) -> str:
|
||||
from praisonaiui.server import _datastore
|
||||
|
||||
session_id = voice_session_id(call_id)
|
||||
existing = await _datastore.get_session(session_id)
|
||||
if existing is None:
|
||||
await _datastore.create_session(session_id)
|
||||
await _datastore.update_session(
|
||||
session_id,
|
||||
platform="voice",
|
||||
icon="📞",
|
||||
title=f"📞 Call {call_id[:8]}",
|
||||
)
|
||||
return session_id
|
||||
|
||||
|
||||
async def _process_item(item: dict[str, Any]) -> None:
|
||||
from praisonaiui.features.chat import get_chat_manager
|
||||
from praisonaiui.server import _datastore
|
||||
|
||||
call_id = str(item.get("call_id") or "")
|
||||
if not call_id:
|
||||
return
|
||||
session_id = await _ensure_session(call_id)
|
||||
mgr = get_chat_manager()
|
||||
|
||||
if item.get("kind") == "transcript":
|
||||
role = str(item.get("role") or "unknown")
|
||||
text = str(item.get("text") or "")
|
||||
final = bool(item.get("final"))
|
||||
if not final:
|
||||
await mgr.broadcast(
|
||||
session_id,
|
||||
{
|
||||
"type": "voice_transcript_partial",
|
||||
"session_id": session_id,
|
||||
"call_id": call_id,
|
||||
"role": role,
|
||||
"content": text,
|
||||
},
|
||||
)
|
||||
return
|
||||
content = f"**{role}:** {text}"
|
||||
await _datastore.add_message(
|
||||
session_id,
|
||||
{"role": "user" if role == "user" else "assistant", "content": content, "platform": "voice"},
|
||||
)
|
||||
await mgr.broadcast(
|
||||
session_id,
|
||||
{
|
||||
"type": "voice_transcript",
|
||||
"session_id": session_id,
|
||||
"call_id": call_id,
|
||||
"role": role,
|
||||
"content": text,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if item.get("kind") == "summary":
|
||||
summary = str(item.get("summary") or "(call ended)")
|
||||
status = str(item.get("status") or "ended")
|
||||
content = f"**Call {status}**\n\n{summary}"
|
||||
await _datastore.add_message(session_id, {"role": "assistant", "content": content, "platform": "voice"})
|
||||
await mgr.broadcast(
|
||||
session_id,
|
||||
{
|
||||
"type": "voice_call_ended",
|
||||
"session_id": session_id,
|
||||
"call_id": call_id,
|
||||
"status": status,
|
||||
"summary": summary,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def run_chat_bridge_worker() -> None:
|
||||
while True:
|
||||
try:
|
||||
item = await asyncio.to_thread(_bridge_queue.get, True, 1.0)
|
||||
except queue.Empty:
|
||||
continue
|
||||
try:
|
||||
await _process_item(item)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("Voice chat bridge error: %s", exc)
|
||||
|
||||
|
||||
def start_chat_bridge_worker() -> None:
|
||||
global _worker_task
|
||||
with _worker_lock:
|
||||
if _worker_task is not None and not _worker_task.done():
|
||||
return
|
||||
loop = asyncio.get_running_loop()
|
||||
_worker_task = loop.create_task(run_chat_bridge_worker())
|
||||
@@ -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,74 @@
|
||||
"""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
|
||||
public_api_key: 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,
|
||||
public_api_key=_env("VOICE_PUBLIC_API_KEY") or None,
|
||||
api_base=api_base.rstrip("/"),
|
||||
)
|
||||
|
||||
|
||||
def get_web_sdk_settings() -> tuple[str, str]:
|
||||
"""Browser SDK URL + legacy global name (global unused for ESM imports)."""
|
||||
url = _env("VOICE_WEB_SDK_URL") or "https://esm.sh/@vapi-ai/[email protected]"
|
||||
global_name = _env("VOICE_WEB_SDK_GLOBAL") or "Vapi"
|
||||
return url, global_name
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Voice agent stack diagnostics (OpenClaw-style doctor)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
def _tcp_open(host: str, port: int, timeout: float = 2.0) -> bool:
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=timeout):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
_MCP_PROBE_TIMEOUT_SEC = 2.0
|
||||
|
||||
|
||||
def _is_slow_mcp_probe(detail: str) -> bool:
|
||||
lowered = detail.lower()
|
||||
return any(token in lowered for token in ("timed out", "timeout", "temporarily unavailable"))
|
||||
|
||||
|
||||
def _mcp_servers_probe(app_port: int) -> tuple[str, str]:
|
||||
"""Probe MCP list endpoint; slow stdio connect must not fail the whole doctor run."""
|
||||
status, detail = _http_status(
|
||||
f"http://127.0.0.1:{app_port}/api/mcp/servers",
|
||||
timeout=_MCP_PROBE_TIMEOUT_SEC,
|
||||
)
|
||||
if status == "fail" and _is_slow_mcp_probe(detail):
|
||||
return "warn", detail
|
||||
return status, detail
|
||||
|
||||
|
||||
def _http_status(url: str, timeout: float = 5.0) -> tuple[str, str]:
|
||||
try:
|
||||
req = Request(url, headers={"User-Agent": "praisonai-voice-doctor"})
|
||||
with urlopen(req, timeout=timeout) as resp:
|
||||
code = resp.getcode()
|
||||
if 200 <= code < 500:
|
||||
return "pass", f"HTTP {code}"
|
||||
return "warn", f"HTTP {code}"
|
||||
except HTTPError as exc:
|
||||
if 200 <= exc.code < 500:
|
||||
return "pass", f"HTTP {exc.code}"
|
||||
return "fail", f"HTTP {exc.code}"
|
||||
except URLError as exc:
|
||||
return "fail", str(exc.reason)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return "fail", str(exc)
|
||||
|
||||
|
||||
def _env_check(name: str, *, required: bool = False) -> dict[str, Any]:
|
||||
value = os.getenv(name, "").strip()
|
||||
if value:
|
||||
masked = value[:4] + "…" if len(value) > 8 else "set"
|
||||
return {"name": name, "status": "pass", "detail": masked}
|
||||
if required:
|
||||
return {"name": name, "status": "fail", "detail": "missing"}
|
||||
return {"name": name, "status": "warn", "detail": "not set"}
|
||||
|
||||
|
||||
def run_voice_doctor(*, app_port: int = 8001, sidecar_port: int = 8002) -> dict[str, Any]:
|
||||
"""Run local voice-agent health checks."""
|
||||
checks: list[dict[str, Any]] = []
|
||||
|
||||
checks.append(_env_check("OPENAI_API_KEY", required=True))
|
||||
checks.append(_env_check("ELEVENLABS_API_KEY"))
|
||||
checks.append(_env_check("SPEECH_ENGINE_ID"))
|
||||
checks.append(_env_check("SPEECH_ENGINE_PUBLIC_URL"))
|
||||
checks.append(_env_check("PUBLIC_API_BASE_URL"))
|
||||
checks.append(_env_check("VOICE_PRIVATE_API_KEY"))
|
||||
|
||||
if _tcp_open("127.0.0.1", app_port):
|
||||
checks.append({"name": f"App port {app_port}", "status": "pass", "detail": "listening"})
|
||||
status, detail = _http_status(f"http://127.0.0.1:{app_port}/api/voice/config")
|
||||
checks.append({"name": "Voice config API", "status": status, "detail": detail})
|
||||
status, detail = _http_status(f"http://127.0.0.1:{app_port}/api/voice/speech-engine/config")
|
||||
checks.append({"name": "Speech Engine config", "status": status, "detail": detail})
|
||||
status, detail = _http_status(f"http://127.0.0.1:{app_port}/api/voice/realtime/config")
|
||||
checks.append({"name": "GPT Realtime config", "status": status, "detail": detail})
|
||||
status, detail = _mcp_servers_probe(app_port)
|
||||
checks.append({"name": "MCP servers API", "status": status, "detail": detail})
|
||||
else:
|
||||
checks.append(
|
||||
{
|
||||
"name": f"App port {app_port}",
|
||||
"status": "fail",
|
||||
"detail": "not listening — run .\\start_dev.ps1",
|
||||
}
|
||||
)
|
||||
|
||||
if _tcp_open("127.0.0.1", sidecar_port):
|
||||
checks.append({"name": f"Speech sidecar {sidecar_port}", "status": "pass", "detail": "listening"})
|
||||
else:
|
||||
checks.append(
|
||||
{
|
||||
"name": f"Speech sidecar {sidecar_port}",
|
||||
"status": "warn",
|
||||
"detail": "not listening (ElevenLabs path needs sidecar)",
|
||||
}
|
||||
)
|
||||
|
||||
speech_url = os.getenv("SPEECH_ENGINE_PUBLIC_URL", "").strip().rstrip("/")
|
||||
if speech_url:
|
||||
status, detail = _http_status(speech_url)
|
||||
checks.append({"name": "Speech Engine tunnel", "status": status, "detail": detail})
|
||||
|
||||
passed = sum(1 for c in checks if c["status"] == "pass")
|
||||
warnings = sum(1 for c in checks if c["status"] == "warn")
|
||||
failed = sum(1 for c in checks if c["status"] == "fail")
|
||||
return {
|
||||
"checks": checks,
|
||||
"summary": {"passed": passed, "warnings": warnings, "failed": failed},
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Live transcript pub/sub for voice calls (SSE)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
|
||||
class TranscriptHub:
|
||||
"""Thread-safe hub broadcasting transcript events per call_id."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._queues: dict[str, list[queue.Queue[dict[str, Any]]]] = defaultdict(list)
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def publish(self, call_id: str, event: dict[str, Any]) -> None:
|
||||
if not call_id:
|
||||
return
|
||||
with self._lock:
|
||||
queues = list(self._queues.get(call_id, []))
|
||||
for sub in queues:
|
||||
try:
|
||||
sub.put_nowait(event)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
async def subscribe(self, call_id: str) -> AsyncIterator[dict[str, Any]]:
|
||||
sub: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=256)
|
||||
with self._lock:
|
||||
self._queues[call_id].append(sub)
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
event = await asyncio.to_thread(sub.get, True, 1.0)
|
||||
except queue.Empty:
|
||||
continue
|
||||
yield event
|
||||
finally:
|
||||
with self._lock:
|
||||
if call_id in self._queues and sub in self._queues[call_id]:
|
||||
self._queues[call_id].remove(sub)
|
||||
|
||||
def snapshot_sse(self, event: dict[str, Any]) -> str:
|
||||
return f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
|
||||
|
||||
|
||||
transcript_hub = TranscriptHub()
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Process voice provider webhook events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from integrations.voice.agent_runner import build_assistant_request_response, execute_voice_tool
|
||||
from integrations.voice.config import VoiceSettings
|
||||
from integrations.voice.live import transcript_hub
|
||||
from integrations.voice.store import VoiceCallStore, webhook_event_key
|
||||
|
||||
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 _tool_parameters(item: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Extract tool params from provider payloads (parameters or arguments)."""
|
||||
for key in ("parameters", "arguments"):
|
||||
value = item.get(key)
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
fn = item.get("function")
|
||||
if isinstance(fn, dict):
|
||||
for key in ("parameters", "arguments"):
|
||||
value = fn.get(key)
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
return {}
|
||||
|
||||
|
||||
def _spoken_tool_result(raw: str) -> str:
|
||||
"""Return plain spoken text for the voice pipeline (no newlines)."""
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
if isinstance(data, dict):
|
||||
spoken = data.get("spoken") or data.get("message")
|
||||
if spoken:
|
||||
return str(spoken).replace("\n", " ").strip()
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return str(raw).replace("\n", " ").strip()
|
||||
|
||||
|
||||
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":
|
||||
return build_assistant_request_response(settings)
|
||||
|
||||
if event_type == "tool-calls" and call_id:
|
||||
tool_calls = msg.get("toolCallList") or []
|
||||
if not tool_calls and isinstance(msg.get("toolWithToolCallList"), list):
|
||||
for entry in msg["toolWithToolCallList"]:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
tool_call = entry.get("toolCall")
|
||||
if isinstance(tool_call, dict):
|
||||
tool_calls.append(
|
||||
{
|
||||
"id": tool_call.get("id"),
|
||||
"name": entry.get("name") or (tool_call.get("function") or {}).get("name"),
|
||||
"parameters": _tool_parameters(tool_call),
|
||||
"arguments": _tool_parameters(tool_call),
|
||||
}
|
||||
)
|
||||
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 "")
|
||||
if not name:
|
||||
fn = item.get("function")
|
||||
if isinstance(fn, dict):
|
||||
name = str(fn.get("name") or "")
|
||||
params = _tool_parameters(item)
|
||||
result = _spoken_tool_result(execute_voice_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()
|
||||
kind = str(msg.get("transcriptType") or "final")
|
||||
if text:
|
||||
line = f"{role}: {text}"
|
||||
if kind == "final":
|
||||
VoiceCallStore.append_transcript_line(call_id, line)
|
||||
else:
|
||||
VoiceCallStore.upsert_call(
|
||||
call_id,
|
||||
status="in-progress",
|
||||
customer_number=_customer_number(payload),
|
||||
metadata={"partial_transcript": line},
|
||||
)
|
||||
transcript_hub.publish(
|
||||
call_id,
|
||||
{"type": "transcript", "role": role, "text": text, "transcriptType": kind},
|
||||
)
|
||||
from integrations.voice.chat_bridge import enqueue_transcript
|
||||
|
||||
enqueue_transcript(call_id, role, text, final=kind == "final")
|
||||
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},
|
||||
)
|
||||
from integrations.voice.call_finalize import finalize_call
|
||||
|
||||
asyncio.run(finalize_call(call_id, status=f"ended ({ended})"))
|
||||
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,180 @@
|
||||
"""OpenAI Realtime API — browser WebRTC voice (gpt-realtime-2.1-mini)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OPENAI_REALTIME_CALLS_URL = "https://api.openai.com/v1/realtime/calls"
|
||||
OPENAI_CLIENT_SECRETS_URL = "https://api.openai.com/v1/realtime/client_secrets"
|
||||
|
||||
|
||||
class RealtimeConfigError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RealtimeSettings:
|
||||
api_key: str
|
||||
model: str
|
||||
voice: str
|
||||
reasoning_effort: str
|
||||
instructions: str
|
||||
first_message: str | None
|
||||
|
||||
|
||||
def load_realtime_settings() -> RealtimeSettings:
|
||||
api_key = os.getenv("OPENAI_API_KEY", "").strip()
|
||||
if not api_key:
|
||||
raise RealtimeConfigError("Set OPENAI_API_KEY in .env")
|
||||
first_message = os.getenv("REALTIME_FIRST_MESSAGE", "").strip() or None
|
||||
return RealtimeSettings(
|
||||
api_key=api_key,
|
||||
model=os.getenv("REALTIME_MODEL", "gpt-realtime-2.1-mini").strip(),
|
||||
voice=os.getenv("REALTIME_VOICE", "marin").strip(),
|
||||
reasoning_effort=os.getenv("REALTIME_REASONING_EFFORT", "low").strip(),
|
||||
instructions=os.getenv(
|
||||
"REALTIME_INSTRUCTIONS",
|
||||
"You are a helpful voice assistant for PraisonAI demos. "
|
||||
"Keep every reply to one or two short sentences.",
|
||||
).strip(),
|
||||
first_message=first_message,
|
||||
)
|
||||
|
||||
|
||||
def realtime_enabled() -> bool:
|
||||
try:
|
||||
load_realtime_settings()
|
||||
except RealtimeConfigError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def build_session_config(
|
||||
settings: RealtimeSettings | None = None,
|
||||
*,
|
||||
praison_session_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
from integrations.voice.session_memory import instructions_with_memory
|
||||
|
||||
cfg = settings or load_realtime_settings()
|
||||
session: dict[str, Any] = {
|
||||
"type": "realtime",
|
||||
"model": cfg.model,
|
||||
"instructions": instructions_with_memory(cfg.instructions, session_id=praison_session_id),
|
||||
"audio": {"output": {"voice": cfg.voice}},
|
||||
# Required for user transcript events in the browser data channel.
|
||||
"input_audio_transcription": {
|
||||
"model": os.getenv("REALTIME_TRANSCRIBE_MODEL", "gpt-4o-mini-transcribe"),
|
||||
},
|
||||
}
|
||||
if cfg.reasoning_effort:
|
||||
session["reasoning"] = {"effort": cfg.reasoning_effort}
|
||||
return session
|
||||
|
||||
|
||||
def new_call_id() -> str:
|
||||
return f"rt-{uuid.uuid4().hex[:12]}"
|
||||
|
||||
|
||||
async def create_webrtc_session(sdp_offer: str, *, call_id: str | None = None) -> str:
|
||||
"""Relay browser SDP to OpenAI unified Realtime calls endpoint."""
|
||||
from integrations.voice.session_memory import resolve_praison_session_id
|
||||
|
||||
settings = load_realtime_settings()
|
||||
praison_session_id = resolve_praison_session_id(call_id)
|
||||
session_json = json.dumps(build_session_config(settings, praison_session_id=praison_session_id))
|
||||
files = {
|
||||
"sdp": (None, sdp_offer, "application/sdp"),
|
||||
"session": (None, session_json, "application/json"),
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
OPENAI_REALTIME_CALLS_URL,
|
||||
headers={"Authorization": f"Bearer {settings.api_key}"},
|
||||
files=files,
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
detail = response.text[:500]
|
||||
logger.error("OpenAI Realtime session failed (%s): %s", response.status_code, detail)
|
||||
raise RealtimeConfigError(f"OpenAI Realtime session failed: HTTP {response.status_code}")
|
||||
return response.text
|
||||
|
||||
|
||||
async def mint_client_secret() -> str:
|
||||
"""Mint ephemeral key (alternative to unified SDP relay)."""
|
||||
settings = load_realtime_settings()
|
||||
payload = {"session": build_session_config(settings)}
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
OPENAI_CLIENT_SECRETS_URL,
|
||||
headers={
|
||||
"Authorization": f"Bearer {settings.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=payload,
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
detail = response.text[:500]
|
||||
raise RealtimeConfigError(f"OpenAI client secret failed: HTTP {response.status_code} — {detail}")
|
||||
data = response.json()
|
||||
token = data.get("value") or data.get("client_secret", {}).get("value")
|
||||
if not token:
|
||||
raise RealtimeConfigError("OpenAI client secret response missing value")
|
||||
return str(token)
|
||||
|
||||
|
||||
def persist_transcript_line(call_id: str, role: str, text: str) -> None:
|
||||
from integrations.voice.live import transcript_hub
|
||||
from integrations.voice.store import VoiceCallStore
|
||||
|
||||
text = text.strip()
|
||||
if not call_id or not text:
|
||||
return
|
||||
record = VoiceCallStore.get_call(call_id) or {}
|
||||
prefix = f"{role}: {text}"
|
||||
existing = (record.get("transcript") or "").strip()
|
||||
merged = f"{existing}\n{prefix}".strip() if existing else prefix
|
||||
VoiceCallStore.upsert_call(
|
||||
call_id,
|
||||
status=record.get("status") or "in-progress",
|
||||
customer_number="web-openai-realtime",
|
||||
transcript=merged,
|
||||
)
|
||||
transcript_hub.publish(
|
||||
call_id,
|
||||
{
|
||||
"type": "transcript",
|
||||
"role": role,
|
||||
"transcriptType": "final",
|
||||
"text": text,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def mark_session_started(call_id: str) -> None:
|
||||
from integrations.voice.store import VoiceCallStore
|
||||
|
||||
VoiceCallStore.upsert_call(
|
||||
call_id,
|
||||
status="in-progress",
|
||||
customer_number="web-openai-realtime",
|
||||
)
|
||||
|
||||
|
||||
def mark_session_ended(call_id: str) -> None:
|
||||
from integrations.voice.store import VoiceCallStore
|
||||
|
||||
VoiceCallStore.upsert_call(
|
||||
call_id,
|
||||
status="ended (customer-ended-call)",
|
||||
customer_number="web-openai-realtime",
|
||||
)
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Cross-call voice memory via PraisonAI session reload by stable user ID."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CALL_USER: dict[str, str] = {}
|
||||
|
||||
|
||||
def memory_enabled() -> bool:
|
||||
return os.getenv("VOICE_MEMORY_ENABLED", "true").lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
def voice_user_session_id(user_id: str) -> str:
|
||||
clean = str(user_id or "").strip()
|
||||
if not clean:
|
||||
raise ValueError("user_id required")
|
||||
return f"voice-user-{clean}"
|
||||
|
||||
|
||||
def attach_pending_user(user_id: str) -> None:
|
||||
from integrations.voice.store import VoiceCallStore
|
||||
|
||||
clean = str(user_id or "").strip()
|
||||
if clean:
|
||||
VoiceCallStore.set_pending_user(clean)
|
||||
|
||||
|
||||
def consume_pending_user() -> str | None:
|
||||
from integrations.voice.store import VoiceCallStore
|
||||
|
||||
return VoiceCallStore.consume_pending_user()
|
||||
|
||||
|
||||
def bind_call_user(call_id: str, user_id: str) -> str:
|
||||
"""Associate a provider call/conversation id with a stable browser user id."""
|
||||
from integrations.voice.store import VoiceCallStore
|
||||
|
||||
call_id = str(call_id or "").strip()
|
||||
user_id = str(user_id or "").strip()
|
||||
if not call_id or not user_id:
|
||||
raise ValueError("call_id and user_id required")
|
||||
session_id = voice_user_session_id(user_id)
|
||||
_CALL_USER[call_id] = user_id
|
||||
VoiceCallStore.upsert_call(
|
||||
call_id,
|
||||
metadata={"user_id": user_id, "praison_session_id": session_id},
|
||||
)
|
||||
return session_id
|
||||
|
||||
|
||||
def resolve_call_user(call_id: str | None) -> str | None:
|
||||
if not call_id:
|
||||
return None
|
||||
if call_id in _CALL_USER:
|
||||
return _CALL_USER[call_id]
|
||||
from integrations.voice.store import VoiceCallStore
|
||||
|
||||
record = VoiceCallStore.get_call(call_id)
|
||||
if not record:
|
||||
return None
|
||||
user_id = (record.get("metadata") or {}).get("user_id")
|
||||
if user_id:
|
||||
_CALL_USER[call_id] = str(user_id)
|
||||
return str(user_id) if user_id else None
|
||||
|
||||
|
||||
def resolve_praison_session_id(call_id: str | None) -> str | None:
|
||||
user_id = resolve_call_user(call_id)
|
||||
if not user_id:
|
||||
return None
|
||||
return voice_user_session_id(user_id)
|
||||
|
||||
|
||||
def _history_limit() -> int:
|
||||
try:
|
||||
return max(1, int(os.getenv("VOICE_MEMORY_HISTORY_LIMIT", "20")))
|
||||
except ValueError:
|
||||
return 20
|
||||
|
||||
|
||||
def load_session_history(session_id: str) -> list[dict[str, str]]:
|
||||
"""Load prior turns from PraisonAI DefaultSessionStore."""
|
||||
try:
|
||||
from praisonaiagents.session.store import DefaultSessionStore
|
||||
|
||||
store = DefaultSessionStore()
|
||||
return store.get_chat_history(session_id, max_messages=_history_limit())
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("Failed to load session history for %s", session_id, exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
def append_session_turn(session_id: str, role: str, content: str) -> None:
|
||||
"""Persist one voice turn into the PraisonAI session folder."""
|
||||
text = str(content or "").strip()
|
||||
if not session_id or not text:
|
||||
return
|
||||
try:
|
||||
from praisonaiagents.session.store import DefaultSessionStore
|
||||
|
||||
store = DefaultSessionStore()
|
||||
if role == "user":
|
||||
store.add_user_message(session_id, text, metadata={"platform": "voice"})
|
||||
else:
|
||||
store.add_assistant_message(session_id, text, metadata={"platform": "voice"})
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("Failed to append session turn for %s", session_id, exc_info=True)
|
||||
|
||||
|
||||
async def ensure_ui_session(session_id: str, *, user_id: str | None = None, call_id: str | None = None) -> None:
|
||||
"""Ensure PraisonAIUI datastore session exists for sidebar reload."""
|
||||
try:
|
||||
from praisonaiui.server import _datastore
|
||||
except Exception: # noqa: BLE001
|
||||
return
|
||||
existing = await _datastore.get_session(session_id)
|
||||
title = "Voice memory"
|
||||
if call_id:
|
||||
title = f"Voice user {user_id or call_id[:8]}"
|
||||
if existing is None:
|
||||
await _datastore.create_session(session_id)
|
||||
await _datastore.update_session(
|
||||
session_id,
|
||||
platform="voice",
|
||||
icon="🎙️",
|
||||
title=title,
|
||||
)
|
||||
|
||||
|
||||
async def append_ui_message(session_id: str, role: str, content: str) -> None:
|
||||
try:
|
||||
from praisonaiui.server import _datastore
|
||||
|
||||
await _datastore.add_message(
|
||||
session_id,
|
||||
{"role": "user" if role == "user" else "assistant", "content": content, "platform": "voice"},
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("Failed to append UI message for %s", session_id, exc_info=True)
|
||||
|
||||
|
||||
def _apply_knowledge_augmentation(prompt: str) -> str:
|
||||
from praisonaiui.features.knowledge import get_knowledge_manager
|
||||
|
||||
k_mgr = get_knowledge_manager()
|
||||
if not k_mgr.list_all():
|
||||
return prompt
|
||||
results = k_mgr.search(prompt, limit=5)
|
||||
lines = [r.get("text", "") for r in results if r.get("text")]
|
||||
if not lines:
|
||||
return prompt
|
||||
block = "\n".join(lines)
|
||||
return f"[Knowledge Context]\n{block}\n[/Knowledge Context]\n\n{prompt}"
|
||||
|
||||
|
||||
def build_knowledge_augmented_prompt(prompt: str, *, timeout_s: float | None = 2.0) -> str:
|
||||
"""Inject knowledge search results like dashboard chat run_agent().
|
||||
|
||||
Knowledge indexing can take tens of seconds on cold start; Speech Engine
|
||||
disconnects if the LLM path blocks too long, so callers use a short timeout.
|
||||
"""
|
||||
if not os.getenv("VOICE_MEMORY_USE_KNOWLEDGE", "true").lower() in ("1", "true", "yes"):
|
||||
return prompt
|
||||
try:
|
||||
if timeout_s is None:
|
||||
return _apply_knowledge_augmentation(prompt)
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
|
||||
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
future = pool.submit(_apply_knowledge_augmentation, prompt)
|
||||
return future.result(timeout=timeout_s)
|
||||
except FuturesTimeoutError:
|
||||
logger.warning("Knowledge augmentation timed out after %.1fs; using plain prompt", timeout_s)
|
||||
return prompt
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("Knowledge augmentation skipped", exc_info=True)
|
||||
return prompt
|
||||
|
||||
|
||||
def history_context_block(session_id: str | None) -> str:
|
||||
if not memory_enabled() or not session_id:
|
||||
return ""
|
||||
history = load_session_history(session_id)
|
||||
if not history:
|
||||
return ""
|
||||
lines = []
|
||||
for item in history[-_history_limit() :]:
|
||||
role = item.get("role", "user")
|
||||
content = str(item.get("content") or "").strip()
|
||||
if content:
|
||||
lines.append(f"{role}: {content}")
|
||||
if not lines:
|
||||
return ""
|
||||
return "Previous conversation with this user:\n" + "\n".join(lines)
|
||||
|
||||
|
||||
def openai_messages_with_memory(
|
||||
transcript_lines: list[str],
|
||||
*,
|
||||
session_id: str | None,
|
||||
base_instructions: str,
|
||||
) -> list[dict[str, str]]:
|
||||
messages: list[dict[str, str]] = [{"role": "system", "content": base_instructions}]
|
||||
prior = history_context_block(session_id)
|
||||
if prior:
|
||||
messages.append({"role": "system", "content": prior})
|
||||
for line in transcript_lines:
|
||||
if line.startswith("user:"):
|
||||
messages.append({"role": "user", "content": line[5:].strip()})
|
||||
elif line.startswith("agent:"):
|
||||
messages.append({"role": "assistant", "content": line[6:].strip()})
|
||||
return messages
|
||||
|
||||
|
||||
def bind_call_from_pending(call_id: str) -> str | None:
|
||||
user_id = consume_pending_user()
|
||||
if not user_id:
|
||||
return None
|
||||
return bind_call_user(call_id, user_id)
|
||||
|
||||
|
||||
async def record_voice_turn(
|
||||
call_id: str | None,
|
||||
*,
|
||||
user_text: str | None = None,
|
||||
agent_text: str | None = None,
|
||||
) -> None:
|
||||
"""Save latest user/agent lines to Praison session + chat UI."""
|
||||
session_id = resolve_praison_session_id(call_id)
|
||||
if not session_id:
|
||||
return
|
||||
user_id = resolve_call_user(call_id)
|
||||
await ensure_ui_session(session_id, user_id=user_id, call_id=call_id)
|
||||
if user_text:
|
||||
append_session_turn(session_id, "user", user_text)
|
||||
await append_ui_message(session_id, "user", user_text)
|
||||
if agent_text:
|
||||
append_session_turn(session_id, "assistant", agent_text)
|
||||
await append_ui_message(session_id, "assistant", agent_text)
|
||||
|
||||
|
||||
def instructions_with_memory(base: str, *, session_id: str | None) -> str:
|
||||
prior = history_context_block(session_id)
|
||||
if not prior:
|
||||
return base
|
||||
return f"{base}\n\n{prior}\nUse prior conversation when the user refers to earlier calls."
|
||||
@@ -0,0 +1,346 @@
|
||||
"""ElevenLabs Speech Engine — WebSocket LLM bridge for browser voice."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_engine_resource: Any | None = None
|
||||
_engine_lock = asyncio.Lock()
|
||||
|
||||
|
||||
class SpeechEngineConfigError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpeechEngineSettings:
|
||||
api_key: str
|
||||
engine_id: str | None
|
||||
public_ws_url: str | None
|
||||
first_message: str
|
||||
|
||||
|
||||
def _public_ws_url() -> str | None:
|
||||
speech_base = os.getenv("SPEECH_ENGINE_PUBLIC_URL", "").strip().rstrip("/")
|
||||
base = speech_base or os.getenv("PUBLIC_API_BASE_URL", "").strip().rstrip("/")
|
||||
if not base:
|
||||
return None
|
||||
if base.startswith("https://"):
|
||||
return "wss://" + base[len("https://") :] + "/ws"
|
||||
if base.startswith("http://"):
|
||||
return "ws://" + base[len("http://") :] + "/ws"
|
||||
return None
|
||||
|
||||
|
||||
def load_speech_engine_settings(*, require_engine_id: bool = False) -> SpeechEngineSettings:
|
||||
api_key = os.getenv("ELEVENLABS_API_KEY", "").strip()
|
||||
if not api_key:
|
||||
raise SpeechEngineConfigError("Set ELEVENLABS_API_KEY in .env")
|
||||
engine_id = os.getenv("SPEECH_ENGINE_ID", "").strip() or None
|
||||
if require_engine_id and not engine_id:
|
||||
raise SpeechEngineConfigError("Set SPEECH_ENGINE_ID in .env (run setup_speech_engine.py)")
|
||||
return SpeechEngineSettings(
|
||||
api_key=api_key,
|
||||
engine_id=engine_id,
|
||||
public_ws_url=_public_ws_url(),
|
||||
first_message=os.getenv(
|
||||
"SPEECH_ENGINE_FIRST_MESSAGE", "Hello! How can I help you today?"
|
||||
).strip(),
|
||||
)
|
||||
|
||||
|
||||
def speech_engine_enabled() -> bool:
|
||||
try:
|
||||
settings = load_speech_engine_settings()
|
||||
except SpeechEngineConfigError:
|
||||
return False
|
||||
return bool(settings.engine_id and settings.public_ws_url)
|
||||
|
||||
|
||||
async def get_engine_resource():
|
||||
"""Lazy-load the ElevenLabs SpeechEngineResource."""
|
||||
global _engine_resource
|
||||
if _engine_resource is not None:
|
||||
return _engine_resource
|
||||
|
||||
async with _engine_lock:
|
||||
if _engine_resource is not None:
|
||||
return _engine_resource
|
||||
settings = load_speech_engine_settings(require_engine_id=True)
|
||||
try:
|
||||
from elevenlabs import AsyncElevenLabs
|
||||
except ImportError as exc:
|
||||
raise SpeechEngineConfigError(
|
||||
"Install Speech Engine SDK: pip install 'elevenlabs>=2.47.0'"
|
||||
) from exc
|
||||
|
||||
if not hasattr(AsyncElevenLabs(api_key=settings.api_key), "speech_engine"):
|
||||
raise SpeechEngineConfigError(
|
||||
"elevenlabs package is too old. Run: pip install -U 'elevenlabs>=2.47.0'"
|
||||
)
|
||||
|
||||
client = AsyncElevenLabs(api_key=settings.api_key)
|
||||
_engine_resource = await client.speech_engine.get(settings.engine_id)
|
||||
return _engine_resource
|
||||
|
||||
|
||||
def _conversation_id(session: Any) -> str | None:
|
||||
cid = getattr(session, "conversation_id", None)
|
||||
return str(cid) if cid else None
|
||||
|
||||
|
||||
def _transcript_display_text(transcript: list[Any], agent_reply: str | None = None) -> str:
|
||||
text = _transcript_to_prompt(transcript)
|
||||
if agent_reply:
|
||||
agent_line = f"agent: {agent_reply.strip()}"
|
||||
text = f"{text}\n{agent_line}".strip() if text else agent_line
|
||||
return text
|
||||
|
||||
|
||||
def _persist_conversation_turn(call_id: str, transcript: list[Any], agent_reply: str) -> None:
|
||||
from integrations.voice.live import transcript_hub
|
||||
from integrations.voice.store import VoiceCallStore
|
||||
|
||||
display = _transcript_display_text(transcript, agent_reply)
|
||||
VoiceCallStore.upsert_call(
|
||||
call_id,
|
||||
status="in-progress",
|
||||
customer_number="web-speech-engine",
|
||||
transcript=display,
|
||||
)
|
||||
prompt = _transcript_to_prompt(transcript)
|
||||
if prompt:
|
||||
last_line = prompt.splitlines()[-1]
|
||||
role, _, content = last_line.partition(": ")
|
||||
transcript_hub.publish(
|
||||
call_id,
|
||||
{
|
||||
"type": "transcript",
|
||||
"role": role or "user",
|
||||
"transcriptType": "final",
|
||||
"text": content,
|
||||
},
|
||||
)
|
||||
transcript_hub.publish(
|
||||
call_id,
|
||||
{
|
||||
"type": "transcript",
|
||||
"role": "assistant",
|
||||
"transcriptType": "final",
|
||||
"text": agent_reply,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _transcript_to_prompt(transcript: list[Any]) -> str:
|
||||
lines: list[str] = []
|
||||
for message in transcript:
|
||||
role = getattr(message, "role", None) or (message.get("role") if isinstance(message, dict) else "user")
|
||||
content = getattr(message, "content", None) or (
|
||||
message.get("content") if isinstance(message, dict) else ""
|
||||
)
|
||||
text = str(content or "").strip()
|
||||
if text:
|
||||
lines.append(f"{role}: {text}")
|
||||
if not lines:
|
||||
return ""
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _try_demo_tool_reply(transcript: list[Any]) -> str | None:
|
||||
from integrations.voice.tools import demo_tool_reply
|
||||
|
||||
user_text = _latest_user_text(transcript)
|
||||
if not user_text:
|
||||
return None
|
||||
return demo_tool_reply(user_text)
|
||||
|
||||
|
||||
async def _run_openai_reply(transcript: list[Any], *, session_id: str | None = None) -> str:
|
||||
"""Voice LLM path with demo fast-path + OpenAI tool calling."""
|
||||
import json
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from integrations.voice.agent_runner import execute_voice_tool
|
||||
from integrations.voice.session_memory import openai_messages_with_memory
|
||||
from integrations.voice.tools import openai_tool_schemas, tool_result_for_voice
|
||||
|
||||
prompt = _transcript_to_prompt(transcript)
|
||||
if not prompt:
|
||||
return "I did not catch that. Could you repeat?"
|
||||
|
||||
fast = _try_demo_tool_reply(transcript)
|
||||
if fast:
|
||||
return fast
|
||||
|
||||
client = AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY", "").strip())
|
||||
model = os.getenv("VOICE_AGENT_MODEL", "gpt-4o-mini")
|
||||
base_instructions = (
|
||||
"You are a helpful voice assistant for PraisonAI demos. "
|
||||
"Keep answers concise (1-3 sentences) and conversational. "
|
||||
"When the caller asks for the time, call get_current_time. "
|
||||
"When they ask you to repeat or echo text, call echo_message."
|
||||
)
|
||||
messages = openai_messages_with_memory(
|
||||
prompt.splitlines(),
|
||||
session_id=session_id,
|
||||
base_instructions=base_instructions,
|
||||
)
|
||||
use_tools = os.getenv("VOICE_SPEECH_USE_TOOLS", "true").lower() in ("1", "true", "yes")
|
||||
create_kwargs: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"max_tokens": 200,
|
||||
}
|
||||
if use_tools:
|
||||
create_kwargs["tools"] = openai_tool_schemas()
|
||||
create_kwargs["tool_choice"] = "auto"
|
||||
|
||||
response = await client.chat.completions.create(**create_kwargs)
|
||||
choice = response.choices[0].message
|
||||
|
||||
if use_tools and choice.tool_calls:
|
||||
messages.append(choice.model_dump(exclude_none=True))
|
||||
fallback = "Done."
|
||||
for tool_call in choice.tool_calls:
|
||||
fn = tool_call.function
|
||||
name = fn.name if fn else ""
|
||||
try:
|
||||
args = json.loads(fn.arguments or "{}") if fn else {}
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
raw = execute_voice_tool(name, args, session_id=session_id)
|
||||
fallback = tool_result_for_voice(raw)
|
||||
messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call.id,
|
||||
"content": raw,
|
||||
}
|
||||
)
|
||||
final = await client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
max_tokens=200,
|
||||
)
|
||||
text = (final.choices[0].message.content or "").strip()
|
||||
return text or fallback
|
||||
|
||||
text = (choice.content or "").strip()
|
||||
return text or "Sorry, I could not generate a response."
|
||||
|
||||
|
||||
async def _run_agent_reply(transcript: list[Any], *, session_id: str | None = None) -> str:
|
||||
prompt = _transcript_to_prompt(transcript)
|
||||
if not prompt:
|
||||
return "I did not catch that. Could you repeat?"
|
||||
|
||||
# Prefer fast OpenAI streaming for Speech Engine turn latency.
|
||||
if os.getenv("OPENAI_API_KEY", "").strip():
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
_run_openai_reply(transcript, session_id=session_id),
|
||||
timeout=20.0,
|
||||
)
|
||||
except TimeoutError:
|
||||
return "Sorry, that took too long. Could you ask again with a shorter question?"
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("OpenAI Speech Engine reply failed; falling back to agent")
|
||||
|
||||
from integrations.voice.agent_runner import get_voice_agent
|
||||
|
||||
agent = get_voice_agent(session_id)
|
||||
reply = await asyncio.wait_for(asyncio.to_thread(agent.chat, prompt), timeout=25.0)
|
||||
return str(reply).strip() or "Sorry, I could not generate a response."
|
||||
|
||||
|
||||
def _latest_user_text(transcript: list[Any]) -> str:
|
||||
prompt = _transcript_to_prompt(transcript)
|
||||
if not prompt:
|
||||
return ""
|
||||
for line in reversed(prompt.splitlines()):
|
||||
if line.startswith("user:"):
|
||||
return line[5:].strip()
|
||||
return ""
|
||||
|
||||
|
||||
async def handle_user_transcript(transcript: list[Any], session: Any) -> None:
|
||||
"""Run voice LLM on transcript and send text back to Speech Engine."""
|
||||
from integrations.voice.session_memory import record_voice_turn, resolve_praison_session_id
|
||||
|
||||
call_id = _conversation_id(session)
|
||||
praison_session_id = resolve_praison_session_id(call_id)
|
||||
preview = _transcript_to_prompt(transcript)
|
||||
print(f"[speech sidecar] transcript: {preview[:200]!r}", flush=True)
|
||||
reply = ""
|
||||
try:
|
||||
reply = await _run_agent_reply(transcript, session_id=praison_session_id)
|
||||
print(f"[speech sidecar] reply: {reply[:200]!r}", flush=True)
|
||||
await session.send_response(reply)
|
||||
except TimeoutError:
|
||||
reply = "Sorry, that took too long. Please try a shorter question."
|
||||
await session.send_response(reply)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("Speech Engine agent reply failed")
|
||||
reply = "Sorry, something went wrong on my side."
|
||||
await session.send_response(reply)
|
||||
if call_id and reply:
|
||||
_persist_conversation_turn(call_id, transcript, reply)
|
||||
user_text = _latest_user_text(transcript)
|
||||
await record_voice_turn(call_id, user_text=user_text or None, agent_text=reply)
|
||||
|
||||
|
||||
async def handle_session_init(conversation_id: str, session: Any) -> None:
|
||||
from integrations.voice.session_memory import bind_call_from_pending, ensure_ui_session, resolve_praison_session_id
|
||||
from integrations.voice.store import VoiceCallStore
|
||||
|
||||
call_id = str(conversation_id)
|
||||
bind_call_from_pending(call_id)
|
||||
VoiceCallStore.upsert_call(
|
||||
call_id,
|
||||
status="in-progress",
|
||||
customer_number="web-speech-engine",
|
||||
)
|
||||
session_id = resolve_praison_session_id(call_id)
|
||||
if session_id:
|
||||
await ensure_ui_session(session_id, call_id=call_id)
|
||||
logger.info("Speech Engine session started: %s", conversation_id)
|
||||
|
||||
|
||||
async def handle_session_close(session: Any) -> None:
|
||||
from integrations.voice.call_finalize import finalize_call
|
||||
|
||||
conversation_id = _conversation_id(session)
|
||||
if conversation_id:
|
||||
await finalize_call(conversation_id, status="ended (customer-ended-call)")
|
||||
logger.info("Speech Engine session ended: %s", conversation_id)
|
||||
|
||||
|
||||
async def handle_session_disconnect(session: Any) -> None:
|
||||
"""Fallback when WebSocket drops without a formal close message."""
|
||||
await handle_session_close(session)
|
||||
|
||||
|
||||
def get_webrtc_token() -> str:
|
||||
"""Mint a browser conversation token (keeps API key server-side)."""
|
||||
settings = load_speech_engine_settings(require_engine_id=True)
|
||||
try:
|
||||
from elevenlabs import ElevenLabs
|
||||
except ImportError as exc:
|
||||
raise SpeechEngineConfigError("Install elevenlabs: pip install elevenlabs") from exc
|
||||
|
||||
client = ElevenLabs(api_key=settings.api_key)
|
||||
response = client.conversational_ai.conversations.get_webrtc_token(
|
||||
agent_id=settings.engine_id,
|
||||
)
|
||||
token = getattr(response, "token", None) or (response.get("token") if isinstance(response, dict) else None)
|
||||
if not token:
|
||||
raise SpeechEngineConfigError("Could not mint Speech Engine conversation token")
|
||||
return str(token)
|
||||
@@ -0,0 +1,252 @@
|
||||
"""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
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS voice_memory_pending (
|
||||
key TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
updated_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:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with closing(_connect()) as conn, 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
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO voice_calls (
|
||||
call_id, status, transcript, summary, metadata_json, created_at, updated_at
|
||||
) VALUES (?, 'unknown', ?, '', '{}', ?, ?)
|
||||
ON CONFLICT(call_id) DO UPDATE SET
|
||||
transcript = excluded.transcript,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(call_id, full, now, now),
|
||||
)
|
||||
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,
|
||||
created_at, 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 "{}"),
|
||||
"created_at": row[6],
|
||||
"updated_at": row[7],
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def set_pending_user(user_id: str) -> None:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with closing(_connect()) as conn, conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO voice_memory_pending (key, user_id, updated_at)
|
||||
VALUES ('browser', ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET user_id = excluded.user_id, updated_at = excluded.updated_at
|
||||
""",
|
||||
(user_id, now),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def consume_pending_user() -> str | None:
|
||||
with closing(_connect()) as conn, conn:
|
||||
row = conn.execute(
|
||||
"SELECT user_id FROM voice_memory_pending WHERE key = 'browser'",
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
conn.execute("DELETE FROM voice_memory_pending WHERE key = 'browser'")
|
||||
return str(row[0])
|
||||
|
||||
@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,
|
||||
created_at, 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 "{}"),
|
||||
"created_at": row[6],
|
||||
"updated_at": row[7],
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"""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}"}
|
||||
|
||||
|
||||
def demo_tool_reply(text: str) -> str | None:
|
||||
"""Fast path for common voice tool queries — no LLM round-trip."""
|
||||
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
|
||||
|
||||
|
||||
def openai_tool_schemas() -> list[dict[str, Any]]:
|
||||
"""OpenAI function schemas for Speech Engine / voice LLM paths."""
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_current_time",
|
||||
"description": "Return the current UTC time when the caller asks for the time.",
|
||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "echo_message",
|
||||
"description": "Repeat or echo back a phrase the caller wants repeated.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "The text to echo back to the caller",
|
||||
}
|
||||
},
|
||||
"required": ["message"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def tool_result_for_voice(raw_result: str) -> str:
|
||||
"""Convert tool JSON output to speakable voice text."""
|
||||
try:
|
||||
data = json.loads(raw_result)
|
||||
except json.JSONDecodeError:
|
||||
return raw_result.strip() or "Done."
|
||||
if isinstance(data, dict):
|
||||
spoken = data.get("spoken")
|
||||
if spoken:
|
||||
return str(spoken)
|
||||
err = data.get("error")
|
||||
if err:
|
||||
return f"Sorry, {err}"
|
||||
return raw_result.strip() or "Done."
|
||||
|
||||
|
||||
_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,76 @@
|
||||
"""HTTP routes for stable voice user sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from integrations.voice.session_memory import (
|
||||
attach_pending_user,
|
||||
bind_call_user,
|
||||
memory_enabled,
|
||||
resolve_praison_session_id,
|
||||
voice_user_session_id,
|
||||
)
|
||||
|
||||
|
||||
async def api_voice_memory_config(_request: Request) -> JSONResponse:
|
||||
return JSONResponse({"enabled": memory_enabled()})
|
||||
|
||||
|
||||
async def api_voice_memory_bind(request: Request) -> JSONResponse:
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception: # noqa: BLE001
|
||||
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
||||
call_id = str(payload.get("call_id") or "").strip()
|
||||
user_id = str(payload.get("user_id") or "").strip()
|
||||
if not call_id or not user_id:
|
||||
return JSONResponse({"error": "call_id and user_id required"}, status_code=400)
|
||||
try:
|
||||
session_id = bind_call_user(call_id, user_id)
|
||||
except ValueError as exc:
|
||||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||||
return JSONResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"call_id": call_id,
|
||||
"user_id": user_id,
|
||||
"praison_session_id": session_id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def api_voice_memory_resolve(request: Request) -> JSONResponse:
|
||||
user_id = request.query_params.get("user_id", "").strip()
|
||||
if not user_id:
|
||||
return JSONResponse({"error": "user_id required"}, status_code=400)
|
||||
session_id = voice_user_session_id(user_id)
|
||||
return JSONResponse({"user_id": user_id, "praison_session_id": session_id})
|
||||
|
||||
|
||||
async def api_voice_memory_attach_user(request: Request) -> JSONResponse:
|
||||
"""ElevenLabs: browser attaches user before conversation id exists."""
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception: # noqa: BLE001
|
||||
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
||||
user_id = str(payload.get("user_id") or "").strip()
|
||||
if not user_id:
|
||||
return JSONResponse({"error": "user_id required"}, status_code=400)
|
||||
attach_pending_user(user_id)
|
||||
return JSONResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"user_id": user_id,
|
||||
"praison_session_id": voice_user_session_id(user_id),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def api_voice_memory_for_call(request: Request) -> JSONResponse:
|
||||
call_id = request.query_params.get("call_id", "").strip()
|
||||
if not call_id:
|
||||
return JSONResponse({"error": "call_id required"}, status_code=400)
|
||||
session_id = resolve_praison_session_id(call_id)
|
||||
return JSONResponse({"call_id": call_id, "praison_session_id": session_id})
|
||||
@@ -0,0 +1,781 @@
|
||||
/**
|
||||
* Voice Agent Phase 2 — live transcript + browser talk widget.
|
||||
*
|
||||
* custom.js can run before dashboard.js defines window.aiui, so we wait.
|
||||
*/
|
||||
(function () {
|
||||
function whenReady(fn) {
|
||||
if (window.aiui && typeof window.aiui.registerView === 'function') {
|
||||
fn();
|
||||
return;
|
||||
}
|
||||
var n = 0;
|
||||
var t = setInterval(function () {
|
||||
n += 1;
|
||||
if (window.aiui && typeof window.aiui.registerView === 'function') {
|
||||
clearInterval(t);
|
||||
fn();
|
||||
} else if (n > 150) {
|
||||
clearInterval(t);
|
||||
console.warn('[voice plugin] window.aiui never became ready');
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function el(tag, style, text) {
|
||||
var node = document.createElement(tag);
|
||||
if (style) node.style.cssText = style;
|
||||
if (text != null) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
function currentCallId() {
|
||||
return new URLSearchParams(window.location.search).get('call_id') || '';
|
||||
}
|
||||
|
||||
function voiceUserId() {
|
||||
var key = 'voice-user-id';
|
||||
var id = localStorage.getItem(key);
|
||||
if (!id) {
|
||||
id = 'u-' + Math.random().toString(36).slice(2, 12);
|
||||
localStorage.setItem(key, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
async function postJson(url, body) {
|
||||
var resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function attachVoiceUser() {
|
||||
return postJson('/api/voice/memory/attach-user', { user_id: voiceUserId() });
|
||||
}
|
||||
|
||||
function renderUiComponent(comp) {
|
||||
if (window.aiui && typeof window.aiui.renderComponent === 'function') {
|
||||
return window.aiui.renderComponent(comp);
|
||||
}
|
||||
var wrap = el('div');
|
||||
if (comp.type === 'code_block') {
|
||||
var pre = el(
|
||||
'pre',
|
||||
'margin:0;padding:16px;border-radius:10px;background:var(--db-card-bg,#1f2937);' +
|
||||
'border:1px solid var(--db-border,#334155);white-space:pre-wrap;font-family:ui-monospace,monospace;font-size:13px'
|
||||
);
|
||||
pre.textContent = comp.code || '';
|
||||
wrap.appendChild(pre);
|
||||
wrap.className = 'db-code-block';
|
||||
return wrap;
|
||||
}
|
||||
if (comp.type === 'text') {
|
||||
wrap.textContent = comp.content || '';
|
||||
return wrap;
|
||||
}
|
||||
wrap.textContent = JSON.stringify(comp);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderUiTree(data, container) {
|
||||
if (!data) return;
|
||||
if (data._components) {
|
||||
data._components.forEach(function (c) {
|
||||
container.appendChild(renderUiComponent(c));
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (data.type === 'layout' && data.children) {
|
||||
data.children.forEach(function (c) {
|
||||
container.appendChild(renderUiComponent(c));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function findTranscriptCodeEl(root) {
|
||||
var blocks = root.querySelectorAll('.db-code-block code');
|
||||
if (!blocks.length) {
|
||||
blocks = root.querySelectorAll('.db-code-block pre');
|
||||
}
|
||||
return blocks.length ? blocks[blocks.length - 1] : null;
|
||||
}
|
||||
|
||||
async function fetchJson(url, retries) {
|
||||
var attempts = retries == null ? 1 : retries;
|
||||
var lastErr = null;
|
||||
for (var i = 0; i < attempts; i += 1) {
|
||||
try {
|
||||
var resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
return resp.json();
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (i < attempts - 1) {
|
||||
await new Promise(function (r) {
|
||||
setTimeout(r, 1500);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastErr || new Error('fetch failed');
|
||||
}
|
||||
|
||||
async function resolveCallId() {
|
||||
var fromQuery = currentCallId();
|
||||
if (fromQuery) return fromQuery;
|
||||
var data = await fetchJson('/api/voice/calls');
|
||||
var calls = data.calls || [];
|
||||
var live = calls.find(function (c) {
|
||||
return String(c.status || '').toLowerCase().indexOf('progress') >= 0;
|
||||
});
|
||||
return (live && live.call_id) || (calls[0] && calls[0].call_id) || '';
|
||||
}
|
||||
|
||||
async function loadWebSdk(cfg) {
|
||||
var candidates = [];
|
||||
var configured = cfg.sdk_url || '';
|
||||
if (configured && configured.indexOf('dist/vapi.js') < 0) {
|
||||
candidates.push(configured);
|
||||
}
|
||||
candidates.push('https://esm.sh/@vapi-ai/[email protected]');
|
||||
|
||||
var lastErr = null;
|
||||
for (var i = 0; i < candidates.length; i++) {
|
||||
try {
|
||||
var mod = await import(/* webpackIgnore: true */ candidates[i]);
|
||||
var Sdk = mod.default || mod.Vapi;
|
||||
if (typeof Sdk === 'function') return Sdk;
|
||||
lastErr = new Error('Module loaded but no Vapi class');
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
console.warn('[voice plugin] SDK load failed for', candidates[i], err);
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
'Could not load voice web SDK' + (lastErr && lastErr.message ? ': ' + lastErr.message : '')
|
||||
);
|
||||
}
|
||||
|
||||
function registerVoiceViews() {
|
||||
window.aiui.registerView(
|
||||
'call-detail',
|
||||
async function (container) {
|
||||
container.innerHTML = '';
|
||||
var wrap = el(
|
||||
'div',
|
||||
'padding:20px;display:flex;flex-direction:column;gap:16px;color:var(--db-text,#eee)'
|
||||
);
|
||||
var uiHost = el('div', 'display:flex;flex-direction:column;gap:12px');
|
||||
var statusLine = el(
|
||||
'div',
|
||||
'font-size:12px;color:var(--db-text-dim,#94a3b8)',
|
||||
'Loading PraisonAIUI components…'
|
||||
);
|
||||
wrap.appendChild(uiHost);
|
||||
wrap.appendChild(statusLine);
|
||||
container.appendChild(wrap);
|
||||
|
||||
var es = null;
|
||||
var lines = [];
|
||||
var transcriptEl = null;
|
||||
|
||||
function renderTranscript(fullText) {
|
||||
if (!transcriptEl) return;
|
||||
if (fullText) {
|
||||
transcriptEl.textContent = fullText;
|
||||
return;
|
||||
}
|
||||
transcriptEl.textContent = lines.length ? lines.join('\n') : '(empty)';
|
||||
}
|
||||
|
||||
function appendLine(role, text) {
|
||||
lines.push(role + ': ' + text);
|
||||
renderTranscript('');
|
||||
}
|
||||
|
||||
try {
|
||||
var callId = await resolveCallId();
|
||||
if (!callId) {
|
||||
renderUiTree(
|
||||
{
|
||||
type: 'layout',
|
||||
children: [
|
||||
{
|
||||
type: 'alert',
|
||||
variant: 'info',
|
||||
title: 'Call detail',
|
||||
message: 'No calls yet — start from ElevenLabs talk or GPT Realtime.',
|
||||
},
|
||||
],
|
||||
},
|
||||
uiHost
|
||||
);
|
||||
statusLine.textContent = 'Idle';
|
||||
return;
|
||||
}
|
||||
var uiData = await fetchJson('/api/voice/calls/' + encodeURIComponent(callId) + '/ui');
|
||||
renderUiTree(uiData.layout, uiHost);
|
||||
transcriptEl = findTranscriptCodeEl(uiHost);
|
||||
var detail = await fetchJson('/api/voice/calls/' + encodeURIComponent(callId));
|
||||
if (detail.transcript) {
|
||||
lines = detail.transcript.split('\n').filter(Boolean);
|
||||
renderTranscript(detail.transcript);
|
||||
}
|
||||
statusLine.textContent = 'Live feed connecting…';
|
||||
es = new EventSource(
|
||||
'/api/voice/calls/' + encodeURIComponent(callId) + '/live-transcript'
|
||||
);
|
||||
es.onmessage = function (ev) {
|
||||
try {
|
||||
var payload = JSON.parse(ev.data);
|
||||
if (payload.type === 'snapshot' && payload.transcript) {
|
||||
lines = payload.transcript.split('\n').filter(Boolean);
|
||||
renderTranscript(payload.transcript);
|
||||
statusLine.textContent = 'Live · snapshot loaded';
|
||||
return;
|
||||
}
|
||||
if (payload.type === 'transcript') {
|
||||
if (payload.transcriptType === 'final') {
|
||||
appendLine(payload.role || 'unknown', payload.text || '');
|
||||
} else if (transcriptEl) {
|
||||
transcriptEl.textContent =
|
||||
(lines.length ? lines.join('\n') + '\n' : '') +
|
||||
(payload.role || 'unknown') +
|
||||
': ' +
|
||||
(payload.text || '') +
|
||||
' …';
|
||||
}
|
||||
statusLine.textContent = 'Live · ' + (payload.transcriptType || 'update');
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[voice plugin] SSE parse error', err);
|
||||
}
|
||||
};
|
||||
es.onerror = function () {
|
||||
statusLine.textContent = 'Live feed disconnected (call may have ended)';
|
||||
};
|
||||
} catch (err) {
|
||||
statusLine.textContent = 'Failed to load call: ' + err.message;
|
||||
}
|
||||
|
||||
return function cleanup() {
|
||||
if (es) es.close();
|
||||
};
|
||||
},
|
||||
function () {}
|
||||
);
|
||||
|
||||
window.aiui.registerView(
|
||||
'web-talk',
|
||||
async function (container) {
|
||||
container.innerHTML = '';
|
||||
var wrap = el('div', 'padding:24px;max-width:640px;color:var(--db-text,#eee)');
|
||||
wrap.appendChild(el('h2', 'margin:0 0 12px 0', 'Web voice'));
|
||||
wrap.appendChild(
|
||||
el(
|
||||
'p',
|
||||
'margin:0 0 20px 0;line-height:1.6;color:var(--db-text-dim,#94a3b8)',
|
||||
'Click Start talking, then allow the microphone when Chrome asks. The prompt does not appear until you click the button.'
|
||||
)
|
||||
);
|
||||
var status = el('div', 'margin-bottom:16px;font-size:13px', 'Loading config…');
|
||||
var btn = el(
|
||||
'button',
|
||||
'padding:12px 20px;border-radius:10px;border:none;background:var(--db-accent,#6366f1);' +
|
||||
'color:#fff;font-size:15px;font-weight:600;cursor:pointer',
|
||||
'Start talking'
|
||||
);
|
||||
btn.disabled = true;
|
||||
wrap.appendChild(status);
|
||||
wrap.appendChild(btn);
|
||||
container.appendChild(wrap);
|
||||
|
||||
var cfg = null;
|
||||
var client = null;
|
||||
|
||||
try {
|
||||
cfg = await fetchJson('/api/voice/web-config');
|
||||
if (!cfg.enabled) {
|
||||
status.textContent =
|
||||
cfg.error ||
|
||||
'Web talk is not configured. Set VOICE_PUBLIC_API_KEY, VOICE_ASSISTANT_ID, VOICE_WEB_SDK_URL, VOICE_WEB_SDK_GLOBAL.';
|
||||
btn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
status.textContent = 'Ready — click Start talking, then allow microphone.';
|
||||
btn.disabled = false;
|
||||
} catch (err) {
|
||||
status.textContent = 'Config error: ' + err.message;
|
||||
return;
|
||||
}
|
||||
|
||||
btn.addEventListener('click', async function () {
|
||||
if (client && typeof client.stop === 'function' && btn.textContent === 'End call') {
|
||||
client.stop();
|
||||
client = null;
|
||||
status.textContent = 'Call ended';
|
||||
btn.textContent = 'Start talking';
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
status.textContent = 'Loading web SDK…';
|
||||
try {
|
||||
var Sdk = await loadWebSdk(cfg);
|
||||
client = new Sdk(cfg.public_key);
|
||||
if (typeof client.start === 'function') {
|
||||
await client.start(cfg.assistant_id);
|
||||
} else if (typeof client.startAssistant === 'function') {
|
||||
await client.startAssistant(cfg.assistant_id);
|
||||
} else {
|
||||
throw new Error('SDK has no start() method');
|
||||
}
|
||||
status.textContent = 'In call — allow microphone if Chrome asks. Speak now.';
|
||||
btn.textContent = 'End call';
|
||||
btn.disabled = false;
|
||||
} catch (err) {
|
||||
status.textContent = 'Start failed: ' + err.message;
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
window.aiui.registerView(
|
||||
'eleven-talk',
|
||||
async function (container) {
|
||||
container.innerHTML = '';
|
||||
var wrap = el('div', 'padding:24px;max-width:640px;color:var(--db-text,#eee)');
|
||||
wrap.appendChild(el('h2', 'margin:0 0 12px 0', 'ElevenLabs voice'));
|
||||
wrap.appendChild(
|
||||
el(
|
||||
'p',
|
||||
'margin:0 0 20px 0;line-height:1.6;color:var(--db-text-dim,#94a3b8)',
|
||||
'Speech Engine runs STT/TTS in the browser. Your PraisonAI agent handles replies on the server.'
|
||||
)
|
||||
);
|
||||
var status = el('div', 'margin-bottom:16px;font-size:13px', 'Loading config…');
|
||||
var btn = el(
|
||||
'button',
|
||||
'padding:12px 20px;border-radius:10px;border:none;background:var(--db-accent,#6366f1);' +
|
||||
'color:#fff;font-size:15px;font-weight:600;cursor:pointer',
|
||||
'Start conversation'
|
||||
);
|
||||
btn.disabled = true;
|
||||
wrap.appendChild(status);
|
||||
wrap.appendChild(btn);
|
||||
container.appendChild(wrap);
|
||||
|
||||
var conversation = null;
|
||||
var cfg = null;
|
||||
|
||||
try {
|
||||
cfg = await fetchJson('/api/voice/speech-engine/config', 8);
|
||||
if (!cfg.enabled) {
|
||||
status.textContent =
|
||||
cfg.error ||
|
||||
'Speech Engine not ready. Run: pip install -U "elevenlabs>=2.47.0" then .\\start_dev.ps1 -Restart. ' +
|
||||
'API key needs convai_write permission in ElevenLabs dashboard.';
|
||||
btn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
var toolList = (cfg.tools && cfg.tools.length ? cfg.tools.join(', ') : 'get_current_time, echo_message');
|
||||
status.textContent =
|
||||
'Ready — LLM ' +
|
||||
(cfg.model || 'gpt-4o-mini') +
|
||||
' · tools: ' +
|
||||
toolList +
|
||||
'. Click Start conversation, then allow microphone.';
|
||||
btn.disabled = false;
|
||||
} catch (err) {
|
||||
status.textContent = 'Config error: ' + err.message;
|
||||
return;
|
||||
}
|
||||
|
||||
btn.addEventListener('click', async function () {
|
||||
if (conversation && btn.textContent === 'End conversation') {
|
||||
try {
|
||||
await conversation.endSession();
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
conversation = null;
|
||||
status.textContent = 'Conversation ended';
|
||||
btn.textContent = 'Start conversation';
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
status.textContent = 'Loading ElevenLabs client…';
|
||||
try {
|
||||
var sdkUrl = cfg.client_sdk_url || 'https://esm.sh/@elevenlabs/client';
|
||||
var mod = await import(/* webpackIgnore: true */ sdkUrl);
|
||||
var Conversation = mod.Conversation;
|
||||
if (!Conversation || typeof Conversation.startSession !== 'function') {
|
||||
throw new Error('ElevenLabs client SDK missing Conversation.startSession');
|
||||
}
|
||||
var tokenResp = await fetchJson('/api/voice/speech-engine/token');
|
||||
if (!tokenResp.token) throw new Error('No conversation token returned');
|
||||
await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
await attachVoiceUser();
|
||||
var startOpts = {
|
||||
conversationToken: tokenResp.token,
|
||||
onConnect: function () {
|
||||
status.textContent =
|
||||
'Connected — speak, then pause ~1s so the agent knows you finished.';
|
||||
btn.textContent = 'End conversation';
|
||||
btn.disabled = false;
|
||||
},
|
||||
onDisconnect: function (details) {
|
||||
var reason = details && details.reason ? details.reason : '';
|
||||
if (reason === 'agent' || reason === 'unknown') {
|
||||
reason =
|
||||
'Speech Engine server did not respond in time. Restart .\\start_dev.ps1 -Restart and try again.';
|
||||
} else if (!reason) {
|
||||
reason =
|
||||
'Connection closed (ElevenLabs could not reach your Speech Engine WebSocket).';
|
||||
}
|
||||
status.textContent = 'Disconnected — ' + reason;
|
||||
btn.textContent = 'Start conversation';
|
||||
btn.disabled = false;
|
||||
conversation = null;
|
||||
},
|
||||
onError: function (error) {
|
||||
status.textContent =
|
||||
'Error: ' + (error && error.message ? error.message : String(error));
|
||||
btn.disabled = false;
|
||||
},
|
||||
onStatusChange: function (state) {
|
||||
if (state && state.status) {
|
||||
status.textContent = 'Status: ' + state.status;
|
||||
}
|
||||
},
|
||||
onModeChange: function (mode) {
|
||||
if (!mode || !mode.mode) return;
|
||||
if (mode.mode === 'listening') {
|
||||
status.textContent = 'Listening… speak, then pause when done.';
|
||||
} else if (mode.mode === 'speaking') {
|
||||
status.textContent = 'Agent speaking…';
|
||||
} else {
|
||||
status.textContent = 'Mode: ' + mode.mode;
|
||||
}
|
||||
},
|
||||
onMessage: function (msg) {
|
||||
if (msg && msg.message) {
|
||||
console.log('[eleven-talk]', msg.source || 'unknown', msg.message);
|
||||
}
|
||||
},
|
||||
};
|
||||
if (cfg.first_message) {
|
||||
startOpts.overrides = {
|
||||
agent: { firstMessage: cfg.first_message },
|
||||
};
|
||||
}
|
||||
conversation = await Conversation.startSession(startOpts);
|
||||
} catch (err) {
|
||||
status.textContent = 'Start failed: ' + err.message;
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
window.aiui.registerView(
|
||||
'realtime-talk',
|
||||
async function (container) {
|
||||
container.innerHTML = '';
|
||||
var wrap = el('div', 'padding:24px;max-width:640px;color:var(--db-text,#eee)');
|
||||
wrap.appendChild(el('h2', 'margin:0 0 12px 0', 'GPT Realtime voice'));
|
||||
wrap.appendChild(
|
||||
el(
|
||||
'p',
|
||||
'margin:0 0 20px 0;line-height:1.6;color:var(--db-text-dim,#94a3b8)',
|
||||
'Speech-to-speech via OpenAI gpt-realtime-2.1-mini (WebRTC).'
|
||||
)
|
||||
);
|
||||
var status = el('div', 'margin-bottom:16px;font-size:13px', 'Loading config…');
|
||||
var btn = el(
|
||||
'button',
|
||||
'padding:12px 20px;border-radius:10px;border:none;background:var(--db-accent,#6366f1);' +
|
||||
'color:#fff;font-size:15px;font-weight:600;cursor:pointer',
|
||||
'Start conversation'
|
||||
);
|
||||
btn.disabled = true;
|
||||
wrap.appendChild(status);
|
||||
wrap.appendChild(btn);
|
||||
container.appendChild(wrap);
|
||||
|
||||
var pc = null;
|
||||
var dc = null;
|
||||
var audioEl = null;
|
||||
var localStream = null;
|
||||
var callId = null;
|
||||
var cfg = null;
|
||||
|
||||
async function saveTranscript(role, text) {
|
||||
if (!callId || !text) return;
|
||||
try {
|
||||
await postJson('/api/voice/realtime/transcript', {
|
||||
call_id: callId,
|
||||
role: role,
|
||||
text: text,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[realtime-talk] transcript save failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
function handleRealtimeEvent(event) {
|
||||
if (!event || !event.type) return;
|
||||
if (
|
||||
event.type === 'conversation.item.input_audio_transcription.completed' &&
|
||||
event.transcript
|
||||
) {
|
||||
saveTranscript('user', event.transcript);
|
||||
status.textContent = 'You: ' + event.transcript;
|
||||
}
|
||||
if (event.type === 'response.output_audio_transcript.done' && event.transcript) {
|
||||
saveTranscript('agent', event.transcript);
|
||||
status.textContent = 'Agent: ' + event.transcript;
|
||||
}
|
||||
if (event.type === 'response.output_audio_transcript.delta' && event.delta) {
|
||||
status.textContent = 'Agent speaking…';
|
||||
}
|
||||
if (event.type === 'input_audio_buffer.speech_started') {
|
||||
status.textContent = 'Listening…';
|
||||
}
|
||||
}
|
||||
|
||||
async function endSession() {
|
||||
if (dc) {
|
||||
try {
|
||||
dc.close();
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
dc = null;
|
||||
}
|
||||
if (pc) {
|
||||
try {
|
||||
pc.close();
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
pc = null;
|
||||
}
|
||||
if (localStream) {
|
||||
localStream.getTracks().forEach(function (t) {
|
||||
t.stop();
|
||||
});
|
||||
localStream = null;
|
||||
}
|
||||
if (audioEl && audioEl.parentNode) {
|
||||
audioEl.parentNode.removeChild(audioEl);
|
||||
audioEl = null;
|
||||
}
|
||||
if (callId) {
|
||||
try {
|
||||
await postJson('/api/voice/realtime/end', { call_id: callId });
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
callId = null;
|
||||
status.textContent = 'Conversation ended.';
|
||||
btn.textContent = 'Start conversation';
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
try {
|
||||
cfg = await fetchJson('/api/voice/realtime/config', 8);
|
||||
if (!cfg.enabled) {
|
||||
status.textContent =
|
||||
cfg.error || 'Set OPENAI_API_KEY in .env, then restart .\\start_dev.ps1';
|
||||
btn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
status.textContent = 'Ready (' + (cfg.model || 'gpt-realtime-2.1-mini') + ').';
|
||||
btn.disabled = false;
|
||||
} catch (err) {
|
||||
status.textContent = 'Config error: ' + err.message;
|
||||
return;
|
||||
}
|
||||
|
||||
btn.addEventListener('click', async function () {
|
||||
if (pc && btn.textContent === 'End conversation') {
|
||||
await endSession();
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
status.textContent = 'Starting WebRTC session…';
|
||||
try {
|
||||
var callResp = await postJson('/api/voice/realtime/new-call', {
|
||||
user_id: voiceUserId(),
|
||||
});
|
||||
callId = callResp.call_id;
|
||||
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
pc = new RTCPeerConnection();
|
||||
audioEl = document.createElement('audio');
|
||||
audioEl.autoplay = true;
|
||||
audioEl.style.display = 'none';
|
||||
wrap.appendChild(audioEl);
|
||||
pc.ontrack = function (e) {
|
||||
if (audioEl) audioEl.srcObject = e.streams[0];
|
||||
};
|
||||
localStream.getTracks().forEach(function (track) {
|
||||
pc.addTrack(track, localStream);
|
||||
});
|
||||
dc = pc.createDataChannel('oai-events');
|
||||
dc.addEventListener('open', function () {
|
||||
if (cfg.first_message) {
|
||||
dc.send(
|
||||
JSON.stringify({
|
||||
type: 'response.create',
|
||||
response: {
|
||||
instructions:
|
||||
'Greet the user by saying exactly: ' + cfg.first_message,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
dc.addEventListener('message', function (e) {
|
||||
try {
|
||||
handleRealtimeEvent(JSON.parse(e.data));
|
||||
} catch (err) {
|
||||
console.warn('[realtime-talk] event parse failed', err);
|
||||
}
|
||||
});
|
||||
var offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
var sdpResp = await fetch(
|
||||
'/api/voice/realtime/session?call_id=' + encodeURIComponent(callId),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/sdp' },
|
||||
body: offer.sdp,
|
||||
}
|
||||
);
|
||||
if (!sdpResp.ok) {
|
||||
var errText = await sdpResp.text();
|
||||
throw new Error(errText || 'SDP exchange failed');
|
||||
}
|
||||
var answerSdp = await sdpResp.text();
|
||||
await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
|
||||
status.textContent = 'Connected — speak when ready.';
|
||||
btn.textContent = 'End conversation';
|
||||
btn.disabled = false;
|
||||
} catch (err) {
|
||||
status.textContent = 'Start failed: ' + err.message;
|
||||
await endSession();
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
window.aiui.registerView(
|
||||
'config',
|
||||
async function (container) {
|
||||
container.innerHTML = '';
|
||||
var wrap = el('div', 'padding:24px;max-width:900px;color:var(--db-text,#eee)');
|
||||
wrap.appendChild(el('h2', 'margin:0 0 8px 0', 'Voice stack doctor'));
|
||||
wrap.appendChild(
|
||||
el(
|
||||
'p',
|
||||
'margin:0 0 16px 0;font-size:13px;color:var(--db-text-dim,#94a3b8)',
|
||||
'Checks .env keys, local ports, APIs, and Speech Engine tunnel reachability.'
|
||||
)
|
||||
);
|
||||
var status = el('div', 'margin-bottom:16px;font-size:13px', 'Running checks…');
|
||||
var tableHost = el('div', '');
|
||||
wrap.appendChild(status);
|
||||
wrap.appendChild(tableHost);
|
||||
container.appendChild(wrap);
|
||||
|
||||
function statusColor(st) {
|
||||
if (st === 'pass') return '#22c55e';
|
||||
if (st === 'warn') return '#eab308';
|
||||
return '#ef4444';
|
||||
}
|
||||
|
||||
try {
|
||||
var data = await fetchJson('/api/voice/doctor', 12);
|
||||
var summary = data.summary || {};
|
||||
status.textContent =
|
||||
'Passed ' +
|
||||
(summary.passed || 0) +
|
||||
' · Warnings ' +
|
||||
(summary.warnings || 0) +
|
||||
' · Failed ' +
|
||||
(summary.failed || 0);
|
||||
var rows = (data.checks || []).map(function (c) {
|
||||
return [
|
||||
c.name || '—',
|
||||
c.status || '—',
|
||||
c.detail || '—',
|
||||
];
|
||||
});
|
||||
renderUiTree(
|
||||
{
|
||||
type: 'layout',
|
||||
children: [
|
||||
{
|
||||
type: 'table',
|
||||
headers: ['Check', 'Status', 'Detail'],
|
||||
rows: rows.length ? rows : [['—', '—', '—']],
|
||||
},
|
||||
],
|
||||
},
|
||||
tableHost
|
||||
);
|
||||
var badges = tableHost.querySelectorAll('td');
|
||||
badges.forEach(function (cell) {
|
||||
if (cell.textContent === 'pass' || cell.textContent === 'warn' || cell.textContent === 'fail') {
|
||||
cell.style.color = statusColor(cell.textContent);
|
||||
cell.style.fontWeight = '600';
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
status.textContent = 'Doctor failed: ' + err.message;
|
||||
}
|
||||
},
|
||||
function () {}
|
||||
);
|
||||
|
||||
console.log(
|
||||
'[voice plugin] Registered call-detail + web-talk + eleven-talk + realtime-talk + config views'
|
||||
);
|
||||
|
||||
var path = (window.location.pathname || '').replace(/^\//, '');
|
||||
if (
|
||||
(path === 'web-talk' ||
|
||||
path === 'call-detail' ||
|
||||
path === 'eleven-talk' ||
|
||||
path === 'realtime-talk') &&
|
||||
typeof window.aiui.selectPage === 'function'
|
||||
) {
|
||||
window.aiui.selectPage(path);
|
||||
} else if (
|
||||
path === 'web-talk' ||
|
||||
path === 'call-detail' ||
|
||||
path === 'eleven-talk' ||
|
||||
path === 'realtime-talk'
|
||||
) {
|
||||
var n = 0;
|
||||
var t = setInterval(function () {
|
||||
n += 1;
|
||||
if (typeof window.aiui.selectPage === 'function') {
|
||||
clearInterval(t);
|
||||
window.aiui.selectPage(path);
|
||||
} else if (n > 50) {
|
||||
clearInterval(t);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
whenReady(registerVoiceViews);
|
||||
})();
|
||||
@@ -0,0 +1,115 @@
|
||||
"""HTTP routes for OpenAI Realtime WebRTC voice."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, PlainTextResponse
|
||||
|
||||
from integrations.voice.realtime import (
|
||||
RealtimeConfigError,
|
||||
load_realtime_settings,
|
||||
mark_session_started,
|
||||
new_call_id,
|
||||
persist_transcript_line,
|
||||
realtime_enabled,
|
||||
create_webrtc_session,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def api_realtime_config(_request: Request) -> JSONResponse:
|
||||
try:
|
||||
settings = load_realtime_settings()
|
||||
enabled = realtime_enabled()
|
||||
error = None
|
||||
except RealtimeConfigError as exc:
|
||||
settings = None
|
||||
enabled = False
|
||||
error = str(exc)
|
||||
return JSONResponse(
|
||||
{
|
||||
"enabled": enabled,
|
||||
"error": error,
|
||||
"model": settings.model if settings else None,
|
||||
"voice": settings.voice if settings else None,
|
||||
"reasoning_effort": settings.reasoning_effort if settings else None,
|
||||
"first_message": settings.first_message if settings else None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def api_realtime_new_call(request: Request) -> JSONResponse:
|
||||
from integrations.voice.session_memory import bind_call_user, voice_user_session_id
|
||||
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception: # noqa: BLE001
|
||||
payload = {}
|
||||
call_id = new_call_id()
|
||||
mark_session_started(call_id)
|
||||
user_id = str(payload.get("user_id") or "").strip()
|
||||
praison_session_id = None
|
||||
if user_id:
|
||||
praison_session_id = bind_call_user(call_id, user_id)
|
||||
return JSONResponse(
|
||||
{
|
||||
"call_id": call_id,
|
||||
"user_id": user_id or None,
|
||||
"praison_session_id": praison_session_id or (voice_user_session_id(user_id) if user_id else None),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def api_realtime_session(request: Request) -> PlainTextResponse | JSONResponse:
|
||||
"""Unified WebRTC interface — browser SDP in, OpenAI SDP answer out."""
|
||||
call_id = request.query_params.get("call_id", "").strip()
|
||||
body = await request.body()
|
||||
sdp_offer = body.decode("utf-8").strip()
|
||||
if not sdp_offer:
|
||||
return JSONResponse({"error": "Missing SDP offer body"}, status_code=400)
|
||||
try:
|
||||
sdp_answer = await create_webrtc_session(sdp_offer, call_id=call_id or None)
|
||||
except RealtimeConfigError as exc:
|
||||
return JSONResponse({"error": str(exc)}, status_code=503)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("Realtime WebRTC session failed")
|
||||
return JSONResponse({"error": str(exc)}, status_code=502)
|
||||
if call_id:
|
||||
mark_session_started(call_id)
|
||||
return PlainTextResponse(sdp_answer, media_type="application/sdp")
|
||||
|
||||
|
||||
async def api_realtime_transcript(request: Request) -> JSONResponse:
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception: # noqa: BLE001
|
||||
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
||||
call_id = str(payload.get("call_id") or "").strip()
|
||||
role = str(payload.get("role") or "user").strip()
|
||||
text = str(payload.get("text") or "").strip()
|
||||
if not call_id or not text:
|
||||
return JSONResponse({"error": "call_id and text required"}, status_code=400)
|
||||
from integrations.voice.session_memory import record_voice_turn
|
||||
|
||||
persist_transcript_line(call_id, role, text)
|
||||
if role == "user":
|
||||
await record_voice_turn(call_id, user_text=text)
|
||||
else:
|
||||
await record_voice_turn(call_id, agent_text=text)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
async def api_realtime_end(request: Request) -> JSONResponse:
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception: # noqa: BLE001
|
||||
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
||||
call_id = str(payload.get("call_id") or "").strip()
|
||||
if call_id:
|
||||
from integrations.voice.call_finalize import finalize_call
|
||||
|
||||
await finalize_call(call_id, status="ended (customer-ended-call)")
|
||||
return JSONResponse({"ok": True})
|
||||
@@ -0,0 +1,3 @@
|
||||
httpx
|
||||
elevenlabs>=2.47.0
|
||||
openai
|
||||
@@ -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,172 @@
|
||||
"""Create or update ElevenLabs Speech Engine with the public WebSocket URL.
|
||||
|
||||
Usage:
|
||||
cd examples/python/voice-agent
|
||||
.\\start_dev.ps1 # sets PUBLIC_API_BASE_URL first
|
||||
py -3.13 setup_speech_engine.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent
|
||||
ENV_PATH = _ROOT / ".env"
|
||||
|
||||
|
||||
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:
|
||||
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 _http_to_ws(base: str) -> str:
|
||||
base = base.strip().rstrip("/")
|
||||
if base.startswith("https://"):
|
||||
return "wss://" + base[len("https://") :] + "/ws"
|
||||
if base.startswith("http://"):
|
||||
return "ws://" + base[len("http://") :] + "/ws"
|
||||
raise RuntimeError(f"Unsupported URL: {base}")
|
||||
|
||||
|
||||
def _public_ws_url() -> str:
|
||||
# Prefer dedicated Speech Engine tunnel (sidecar on 8002).
|
||||
speech_base = os.getenv("SPEECH_ENGINE_PUBLIC_URL", "").strip().rstrip("/")
|
||||
if speech_base:
|
||||
return _http_to_ws(speech_base)
|
||||
base = os.getenv("PUBLIC_API_BASE_URL", "").strip().rstrip("/")
|
||||
if not base:
|
||||
raise RuntimeError(
|
||||
"Set SPEECH_ENGINE_PUBLIC_URL or PUBLIC_API_BASE_URL in .env "
|
||||
"(run .\\start_dev.ps1 first)"
|
||||
)
|
||||
return _http_to_ws(base)
|
||||
|
||||
|
||||
async def _patch_overrides(client: object, engine_id: str) -> None:
|
||||
"""Enable client first-message override (not exposed on speech_engine.update yet)."""
|
||||
wrapper = getattr(client, "_client_wrapper", None)
|
||||
if wrapper is None:
|
||||
return
|
||||
try:
|
||||
await wrapper.httpx_client.request(
|
||||
f"v1/speech-engine/{engine_id}",
|
||||
method="PATCH",
|
||||
json={"overrides": {"first_message": True}},
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
print("Enabled first_message client override")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"Note: could not enable first_message override ({exc})")
|
||||
|
||||
|
||||
async def _ensure_engine() -> str:
|
||||
api_key = os.getenv("ELEVENLABS_API_KEY", "").strip()
|
||||
if not api_key:
|
||||
raise RuntimeError("Set ELEVENLABS_API_KEY in .env")
|
||||
|
||||
try:
|
||||
from elevenlabs import AsyncElevenLabs
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"Install Speech Engine SDK: pip install 'elevenlabs>=2.47.0'"
|
||||
) from exc
|
||||
|
||||
if not hasattr(AsyncElevenLabs(api_key="check"), "speech_engine"):
|
||||
raise RuntimeError(
|
||||
"elevenlabs package is too old for Speech Engine. Run: "
|
||||
"pip install -U 'elevenlabs>=2.47.0'"
|
||||
)
|
||||
|
||||
ws_url = _public_ws_url()
|
||||
engine_id = os.getenv("SPEECH_ENGINE_ID", "").strip()
|
||||
client = AsyncElevenLabs(api_key=api_key)
|
||||
|
||||
turn_config = {"turn_eagerness": "eager", "turn_timeout": 5.0}
|
||||
voice_id = os.getenv("ELEVENLABS_VOICE_ID", "").strip() or "cjVigY5qzO86Huf0OWal"
|
||||
tts_config = {"model_id": "eleven_flash_v2", "voice_id": voice_id}
|
||||
|
||||
if engine_id:
|
||||
updated = await client.speech_engine.update(
|
||||
speech_engine_id=engine_id,
|
||||
speech_engine={"ws_url": ws_url},
|
||||
turn=turn_config,
|
||||
tts=tts_config,
|
||||
)
|
||||
resolved = getattr(updated, "speech_engine_id", None) or getattr(updated, "engine_id", None) or engine_id
|
||||
print(f"Updated Speech Engine: {resolved}")
|
||||
print(f"WebSocket URL: {ws_url}")
|
||||
print(f"TTS voice_id: {voice_id}")
|
||||
await _patch_overrides(client, str(resolved))
|
||||
return str(resolved)
|
||||
|
||||
created = await client.speech_engine.create(
|
||||
name="PraisonAI Voice",
|
||||
speech_engine={"ws_url": ws_url},
|
||||
turn=turn_config,
|
||||
tts=tts_config,
|
||||
overrides={"first_message": True},
|
||||
)
|
||||
resolved = getattr(created, "engine_id", None) or getattr(created, "id", None)
|
||||
if not resolved:
|
||||
raise RuntimeError("Speech Engine create did not return an engine_id")
|
||||
print(f"Created Speech Engine: {resolved}")
|
||||
print(f"WebSocket URL: {ws_url}")
|
||||
print(f"TTS voice_id: {voice_id}")
|
||||
return str(resolved)
|
||||
|
||||
|
||||
async def main_async() -> int:
|
||||
_load_env()
|
||||
try:
|
||||
engine_id = await _ensure_engine()
|
||||
except RuntimeError as exc:
|
||||
print(f"Setup failed: {exc}")
|
||||
return 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
message = str(exc)
|
||||
if "convai_write" in message or "missing_permissions" in message:
|
||||
print("Setup failed: ElevenLabs API key lacks convai_write permission.")
|
||||
print(
|
||||
"In ElevenLabs dashboard: API Keys -> edit key -> ElevenAgents: enable Write."
|
||||
)
|
||||
else:
|
||||
print(f"Setup failed: {exc}")
|
||||
return 1
|
||||
|
||||
_write_env_value("SPEECH_ENGINE_ID", engine_id)
|
||||
print("Speech Engine auto-setup complete.")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
return asyncio.run(main_async())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,369 @@
|
||||
"""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:
|
||||
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_assistant_model(api_key: str, api_base: str, assistant_id: str) -> dict[str, Any]:
|
||||
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 {})
|
||||
target = os.getenv("VOICE_AGENT_MODEL", "gpt-4o-mini")
|
||||
if model.get("provider") != "openai" or model.get("model") != target:
|
||||
model["provider"] = "openai"
|
||||
model["model"] = target
|
||||
patched = _request(
|
||||
"PATCH",
|
||||
f"/assistant/{assistant_id}",
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
body={"model": model},
|
||||
)
|
||||
if patched is None:
|
||||
raise RuntimeError(f"Failed to set assistant model to {target}")
|
||||
print(f"Set assistant model to openai/{target}")
|
||||
return model
|
||||
|
||||
|
||||
def ensure_agent_model_env() -> str:
|
||||
"""Ensure VOICE_AGENT_MODEL is set in .env and return the target model."""
|
||||
target = os.getenv("VOICE_AGENT_MODEL", "").strip() or "gpt-4o-mini"
|
||||
if not os.getenv("VOICE_AGENT_MODEL", "").strip():
|
||||
_write_env_value("VOICE_AGENT_MODEL", target)
|
||||
print(f"Set VOICE_AGENT_MODEL={target} in .env")
|
||||
return target
|
||||
|
||||
|
||||
def ensure_openai_credential(api_key: str, api_base: str, openai_api_key: str) -> tuple[str, str]:
|
||||
"""Create or reuse an OpenAI-compatible credential in the provider account."""
|
||||
existing = _request("GET", "/credential", api_key=api_key, api_base=api_base)
|
||||
if isinstance(existing, list):
|
||||
preferred = sorted(
|
||||
[item for item in existing if isinstance(item, dict) and item.get("id")],
|
||||
key=lambda item: 0 if str(item.get("provider") or "") == "openai" else 1,
|
||||
)
|
||||
for item in preferred:
|
||||
provider = str(item.get("provider") or "")
|
||||
if provider in ("openai", "custom-llm"):
|
||||
cred_id = str(item["id"])
|
||||
print(f"Reusing credential ({provider}): {cred_id}")
|
||||
updated = _request(
|
||||
"PATCH",
|
||||
f"/credential/{cred_id}",
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
body={
|
||||
"provider": provider,
|
||||
"apiKey": openai_api_key,
|
||||
"name": "Praison Voice OpenAI",
|
||||
},
|
||||
)
|
||||
if updated is None:
|
||||
raise RuntimeError("Failed to update OpenAI credential")
|
||||
return cred_id, provider
|
||||
|
||||
for provider in ("openai", "custom-llm"):
|
||||
created = _request(
|
||||
"POST",
|
||||
"/credential",
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
body={
|
||||
"provider": provider,
|
||||
"apiKey": openai_api_key,
|
||||
"name": "Praison Voice OpenAI",
|
||||
},
|
||||
)
|
||||
if isinstance(created, dict) and created.get("id"):
|
||||
cred_id = str(created["id"])
|
||||
print(f"Created credential ({provider}): {cred_id}")
|
||||
return cred_id, provider
|
||||
|
||||
raise RuntimeError(
|
||||
"Failed to create OpenAI credential. Add the key manually in the provider dashboard "
|
||||
"under Provider Keys / OpenAI, or enable gpt-4o-mini on your OpenAI project."
|
||||
)
|
||||
|
||||
|
||||
def ensure_openai_on_assistant(
|
||||
api_key: str, api_base: str, assistant_id: str, openai_credential_id: str, *, provider: 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 OpenAI setup")
|
||||
|
||||
target_model = os.getenv("VOICE_AGENT_MODEL", "gpt-4o-mini")
|
||||
if provider == "custom-llm":
|
||||
model = dict(assistant.get("model") or {})
|
||||
model["model"] = target_model
|
||||
model["provider"] = "custom-llm"
|
||||
model["url"] = "https://api.openai.com/v1"
|
||||
model["metadataSendMode"] = "off"
|
||||
model.pop("credentialId", None)
|
||||
else:
|
||||
# Native OpenAI — drop custom-llm-only fields from prior config.
|
||||
model = {"provider": "openai", "model": target_model}
|
||||
|
||||
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 OpenAI credential to assistant")
|
||||
print(f"Configured assistant model ({provider}): {target_model}")
|
||||
|
||||
|
||||
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"
|
||||
target_model = ensure_agent_model_env()
|
||||
openai_api_key = os.getenv("OPENAI_API_KEY", "").strip()
|
||||
|
||||
try:
|
||||
if openai_api_key:
|
||||
cred_id, cred_provider = ensure_openai_credential(api_key, api_base, openai_api_key)
|
||||
ensure_openai_on_assistant(
|
||||
api_key, api_base, assistant_id, cred_id, provider=cred_provider
|
||||
)
|
||||
print(f"Assistant model target: {target_model}")
|
||||
else:
|
||||
print("OPENAI_API_KEY not set — skipping provider OpenAI credential setup")
|
||||
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,110 @@
|
||||
"""HTTP + WebSocket routes for ElevenLabs Speech Engine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.websockets import WebSocket, WebSocketDisconnect
|
||||
|
||||
from integrations.voice.speech_engine import (
|
||||
SpeechEngineConfigError,
|
||||
get_engine_resource,
|
||||
get_webrtc_token,
|
||||
handle_session_close,
|
||||
handle_session_init,
|
||||
handle_user_transcript,
|
||||
load_speech_engine_settings,
|
||||
speech_engine_enabled,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def api_speech_engine_config(_request: Request) -> JSONResponse:
|
||||
try:
|
||||
settings = load_speech_engine_settings()
|
||||
enabled = speech_engine_enabled()
|
||||
error = None
|
||||
except SpeechEngineConfigError as exc:
|
||||
settings = None
|
||||
enabled = False
|
||||
error = str(exc)
|
||||
tools: list[str] = []
|
||||
if enabled:
|
||||
try:
|
||||
from integrations.voice.agent_runner import list_registered_tool_names
|
||||
|
||||
tools = list_registered_tool_names()
|
||||
except Exception: # noqa: BLE001
|
||||
tools = ["get_current_time", "echo_message"]
|
||||
return JSONResponse(
|
||||
{
|
||||
"enabled": enabled,
|
||||
"error": error,
|
||||
"engine_id": settings.engine_id if settings else None,
|
||||
"ws_url": settings.public_ws_url if settings else None,
|
||||
"client_sdk_url": "https://esm.sh/@elevenlabs/client",
|
||||
"first_message": settings.first_message if settings else None,
|
||||
"model": os.getenv("VOICE_AGENT_MODEL", "gpt-4o-mini"),
|
||||
"tools": tools,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def api_speech_engine_token(_request: Request) -> JSONResponse:
|
||||
try:
|
||||
token = get_webrtc_token()
|
||||
except SpeechEngineConfigError as exc:
|
||||
return JSONResponse({"error": str(exc)}, status_code=503)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("Speech Engine token mint failed")
|
||||
return JSONResponse({"error": str(exc)}, status_code=502)
|
||||
return JSONResponse({"token": token})
|
||||
|
||||
|
||||
async def speech_engine_websocket(websocket: WebSocket) -> None:
|
||||
try:
|
||||
engine = await get_engine_resource()
|
||||
except SpeechEngineConfigError as exc:
|
||||
logger.warning("Speech Engine WS rejected: %s", exc)
|
||||
return
|
||||
|
||||
headers = dict(websocket.headers)
|
||||
if hasattr(engine, "verify_request") and not engine.verify_request(headers):
|
||||
logger.warning(
|
||||
"Speech Engine WS unauthorized (missing or invalid ElevenLabs JWT header)"
|
||||
)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
logger.info("Speech Engine WebSocket accepted from ElevenLabs")
|
||||
session = engine.create_session(websocket, debug=False)
|
||||
|
||||
async def on_transcript(transcript, sess): # noqa: ANN001
|
||||
await handle_user_transcript(transcript, sess)
|
||||
|
||||
async def on_init(conversation_id, sess): # noqa: ANN001
|
||||
await handle_session_init(conversation_id, sess)
|
||||
|
||||
async def on_close(sess): # noqa: ANN001
|
||||
await handle_session_close(sess)
|
||||
|
||||
def on_error(err, sess=None): # noqa: ANN001
|
||||
logger.error("Speech Engine session error: %s", err)
|
||||
|
||||
session.on("user_transcript", on_transcript)
|
||||
session.on("init", on_init)
|
||||
session.on("close", on_close)
|
||||
session.on("error", on_error)
|
||||
|
||||
try:
|
||||
await session.run()
|
||||
except WebSocketDisconnect:
|
||||
logger.info("Speech Engine WebSocket disconnected")
|
||||
await handle_session_close(session)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("Speech Engine WebSocket session failed")
|
||||
await handle_session_close(session)
|
||||
Loaded 100 of 312 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user