mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 05:56:17 -10:00
Compare commits
136
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:
|
||||
@@ -65,3 +66,4 @@ jobs:
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./site
|
||||
force_orphan: true
|
||||
@@ -1,46 +1,30 @@
|
||||
name: Claude PR Merge Gate
|
||||
|
||||
# Read-only Claude assesses merge readiness; GH_TOKEN merges (no @claude, no gh pr review).
|
||||
# Does not overlap with merge-conflict-claude.yml or auto-pr-comment @claude chains.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Scan for merge-ready PRs every 10 minutes (assess runs only when candidates exist).
|
||||
- cron: '*/10 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'Optional PR number (scan all open internal PRs if empty)'
|
||||
required: false
|
||||
type: string
|
||||
merge_method:
|
||||
description: 'Merge method'
|
||||
required: false
|
||||
type: choice
|
||||
default: merge
|
||||
options:
|
||||
- merge
|
||||
- squash
|
||||
- rebase
|
||||
options: [merge, squash, rebase]
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Claude Assistant
|
||||
- CI
|
||||
types:
|
||||
- completed
|
||||
- Core Tests
|
||||
types: [completed]
|
||||
repository_dispatch:
|
||||
types:
|
||||
- claude-merge-gate
|
||||
types: [claude-merge-gate]
|
||||
|
||||
concurrency:
|
||||
group: claude-merge-gate-${{ github.repository }}-${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || github.event.workflow_run.id || 'scan' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
MAX_CANDIDATES: 3
|
||||
DISPATCH_PR_NUMBER: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -59,7 +43,9 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected]
|
||||
with:
|
||||
hub_ref: v1.1.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/
|
||||
|
||||
@@ -366,3 +366,7 @@ pytest tests -v
|
||||
## License
|
||||
|
||||
MIT © [Praison Limited](https://praison.ai)
|
||||
|
||||
Third-party vendored source is documented in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
|
||||
See the [BeautifulUI upgrade playbook](docs/frontend/beautifului.md) for how BeautifulUI
|
||||
primitives are vendored and pinned.
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Security Policy
|
||||
|
||||
We take the security of PraisonAIUI seriously and appreciate responsible
|
||||
disclosure of vulnerabilities.
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Security fixes are applied to the latest released version of PraisonAIUI.
|
||||
Please make sure you are on the most recent release before reporting.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Please do not open a public issue for security vulnerabilities.**
|
||||
|
||||
Report privately through GitHub's **Private Vulnerability Reporting**:
|
||||
|
||||
1. Go to the [Security tab](https://github.com/MervinPraison/PraisonAIUI/security)
|
||||
of this repository.
|
||||
2. Click **"Report a vulnerability"** to open a private advisory.
|
||||
3. Include as much detail as you can:
|
||||
- Affected component(s) and version.
|
||||
- Steps to reproduce.
|
||||
- Impact assessment.
|
||||
- Any suggested fix or mitigation.
|
||||
|
||||
If you are unable to use private reporting, you may open a public issue that
|
||||
contains **no technical specifics** and requests a private channel, and a
|
||||
maintainer will follow up.
|
||||
|
||||
## What to Expect
|
||||
|
||||
- We aim to acknowledge new reports within a few business days.
|
||||
- We will keep you informed as we investigate and work on a fix.
|
||||
- Once a fix is released, we are happy to credit you in the advisory (unless
|
||||
you prefer to remain anonymous).
|
||||
|
||||
Thank you for helping keep PraisonAIUI and its users safe.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Third-Party Notices
|
||||
|
||||
PraisonAIUI incorporates or vendors source from the third-party projects listed below.
|
||||
Their licences and copyright notices are retained here as required.
|
||||
|
||||
---
|
||||
|
||||
## BeautifulUI
|
||||
|
||||
- Project: BeautifulUI — <https://www.beautifului.dev/>
|
||||
- Source: <https://github.com/slev12397/beautiful-ui>
|
||||
- Licence: MIT
|
||||
|
||||
BeautifulUI is distributed as a shadcn registry / copy-paste library (no npm package).
|
||||
When its React primitives are vendored into `src/frontend`, the following MIT licence
|
||||
applies to that copied source.
|
||||
|
||||
```
|
||||
MIT License
|
||||
|
||||
Copyright (c) Shane Levine
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
See the upgrade playbook at [`docs/frontend/beautifului.md`](docs/frontend/beautifului.md)
|
||||
for how BeautifulUI is vendored and pinned.
|
||||
+18
-4
@@ -9,6 +9,14 @@ site:
|
||||
radius: "md"
|
||||
darkMode: true
|
||||
|
||||
seo:
|
||||
siteUrl: "https://ui.praison.ai"
|
||||
titleTemplate: "%s | %s"
|
||||
defaultImage: "/icon.svg"
|
||||
robots:
|
||||
index: true
|
||||
follow: true
|
||||
|
||||
# Mintlify-style navigation: few tabs, many groups per tab
|
||||
navigation:
|
||||
tabs:
|
||||
@@ -51,6 +59,11 @@ navigation:
|
||||
- typescript/runtime
|
||||
- typescript/nextjs
|
||||
- typescript/components
|
||||
- group: "Frontend"
|
||||
icon: "layout"
|
||||
prefix: "frontend"
|
||||
pages:
|
||||
- frontend/beautifului
|
||||
- group: "Examples"
|
||||
icon: "folder-open"
|
||||
prefix: "examples"
|
||||
@@ -73,11 +86,12 @@ navigation:
|
||||
# Navbar actions
|
||||
navbar:
|
||||
primary:
|
||||
type: "github"
|
||||
href: "https://github.com/MervinPraison/PraisonAIUI"
|
||||
type: "button"
|
||||
label: "Get started"
|
||||
href: "/docs/getting-started/quickstart"
|
||||
links:
|
||||
- label: "Submit Issues"
|
||||
href: "https://github.com/MervinPraison/PraisonAIUI/issues"
|
||||
- label: "GitHub"
|
||||
href: "https://github.com/MervinPraison/PraisonAIUI"
|
||||
|
||||
# Footer
|
||||
footer:
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# BeautifulUI Integration Strategy
|
||||
|
||||
> **Epic:** [#292](https://github.com/MervinPraison/PraisonAIUI/issues/292) — BeautifulUI integration strategy for AI-native dashboard UX.
|
||||
> This is the durable, agreed strategy that the linked child issues execute against.
|
||||
|
||||
[BeautifulUI](https://www.beautifului.dev/) (MIT, Shane Levine / Turbo) is a **copy-paste / shadcn-registry** library of React primitives for AI-native interfaces: thinking traces, streaming answers with inline sources, HITL approval cards, tool chips, task rows, context chunks, diff/records tables, prompt bars, and a reference chat harness.
|
||||
|
||||
There is **no published npm package** named `beautiful-ui`. It ships only as source consumed via the shadcn registry or manual copy.
|
||||
|
||||
## 1. Strategic decision
|
||||
|
||||
BeautifulUI is adopted as **vendored source**, not a runtime dependency, and is exposed to dashboard plugins **only through the existing `@praisonaiui/react` package** — never by importing upstream file paths directly. Rollout is **phased behind a feature flag**, keeping the current vanilla dashboard as the fallback until parity is proven.
|
||||
|
||||
Upstream stack (Next.js 15 · React 19 · Tailwind v4 · TypeScript) is closely aligned with PraisonAIUI's `src/frontend` (Vite · React 19 · Tailwind v4 · shadcn/Radix), so primitives port with adapter work rather than a runtime bridge.
|
||||
|
||||
## 2. Pillars
|
||||
|
||||
| # | Pillar | Decision |
|
||||
|---|--------|----------|
|
||||
| 1 | **Vendoring model** | Add primitives into `src/frontend` via `npx shadcn add https://www.beautifului.dev/r/<component>.json`, or manual copy from [github.com/slev12397/beautiful-ui](https://github.com/slev12397/beautiful-ui). Pin via lockfile plus a recorded registry URL / git SHA. No `beautiful-ui` npm dependency in production builds. |
|
||||
| 2 | **Adapter layer** | Map PraisonAIUI chat / approval / trace **protocol events** to stable internal props. Primitives sit behind `@praisonaiui/react` exports; dashboard plugins consume those exports, not upstream paths. See [#294](https://github.com/MervinPraison/PraisonAIUI/issues/294). |
|
||||
| 3 | **Hybrid rollout** | Mount React **islands** (or expand `@praisonaiui/react`) for high-value surfaces first — chat streaming/thinking/tools, then approvals. Vanilla chat (`templates/frontend/plugins/views/chat.js`) stays as the flag-gated fallback until parity is proven. |
|
||||
| 4 | **Token strategy** | Merge BeautifulUI foundation CSS **selectively** into the existing shadcn theme generation (`praisonaiui/themes.py`). One design system per page — avoid two competing token sets. |
|
||||
| 5 | **Icon licence** | Upstream `SidebarNav` uses the paid `@central-icons-react`. Port nav patterns with **lucide-react** (already in PraisonAIUI) instead. |
|
||||
|
||||
## 3. Phasing
|
||||
|
||||
1. **Spike** — evaluate primitives in the dev environment, confirm React 19 / Tailwind v4 alignment. [#293](https://github.com/MervinPraison/PraisonAIUI/issues/293)
|
||||
2. **Architecture** — adapter layer + upstream pin policy. [#294](https://github.com/MervinPraison/PraisonAIUI/issues/294)
|
||||
3. **CI gate** — frontend build + BeautifulUI compatibility check (no frontend build gate exists today). [#295](https://github.com/MervinPraison/PraisonAIUI/issues/295)
|
||||
4. **Migrations** — chat streaming / thinking / tool chips ([#296](https://github.com/MervinPraison/PraisonAIUI/issues/296)), then the approval card ([#297](https://github.com/MervinPraison/PraisonAIUI/issues/297)).
|
||||
5. **Docs** — upgrade playbook + MIT attribution. [#298](https://github.com/MervinPraison/PraisonAIUI/issues/298)
|
||||
|
||||
## 4. Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| No semver npm package | Vendor the source; pin to a recorded registry URL / git SHA in the lockfile. |
|
||||
| Upstream breaking registry JSON | Vendoring decouples builds from live registry; re-sync is a deliberate, reviewed step. |
|
||||
| Icon licence (`@central-icons-react` is paid) | Use lucide-react for ported nav patterns; never vendor the paid package. |
|
||||
| Two competing design systems on one page | Selective CSS token merge into `themes.py`; adapter-only prop surface. |
|
||||
| React / Tailwind drift | Upstream (React 19 / Tailwind v4) already matches `src/frontend`; the CI gate catches drift. |
|
||||
|
||||
## 5. Out of scope
|
||||
|
||||
- Replacing the entire dashboard shell in one PR.
|
||||
- PyPI packaging of BeautifulUI.
|
||||
- iframe embedding of beautifului.dev.
|
||||
- Depending on any unpublished npm package for production builds.
|
||||
|
||||
## 6. Acceptance criteria status
|
||||
|
||||
- [x] Written integration strategy agreed (vendoring + adapter + phased rollout) — this document.
|
||||
- [x] Risks documented — section 4.
|
||||
- [x] Child issues filed and prioritised — [#293](https://github.com/MervinPraison/PraisonAIUI/issues/293)–[#298](https://github.com/MervinPraison/PraisonAIUI/issues/298).
|
||||
- [x] No requirement to depend on unpublished npm packages for production builds — pillar 1, out of scope.
|
||||
+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,35 @@
|
||||
# BeautifulUI upstream pin record
|
||||
|
||||
BeautifulUI is vendored (shadcn-style registry copy), **not** an npm dependency.
|
||||
This file is the single source of truth for what upstream revision is vendored.
|
||||
Update it in the **same PR** that adds or bumps any vendored component, and make
|
||||
sure the frontend CI gate (issue #295) passes.
|
||||
|
||||
See [beautifului-adapter-architecture.md](./beautifului-adapter-architecture.md)
|
||||
for the adapter boundary and folder layout.
|
||||
|
||||
## Pin
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Upstream repo | _TBD — record on first vendor_ |
|
||||
| Git SHA | _TBD_ |
|
||||
| Vendored on (date) | _TBD_ |
|
||||
| Registry base URL | _TBD_ |
|
||||
| License | MIT (kept in-tree) |
|
||||
|
||||
## Vendored components
|
||||
|
||||
Record one row per component the first time it is copied in.
|
||||
|
||||
| Component | Registry URL | SHA | Vendored path | Date |
|
||||
|-----------|--------------|-----|---------------|------|
|
||||
| _(none yet)_ | | | | |
|
||||
|
||||
## Upgrade checklist
|
||||
|
||||
1. Update the SHA / date / registry rows above.
|
||||
2. Re-copy changed components into `src/frontend/src/agent-ui/vendors/beautifului/`.
|
||||
3. Keep all upstream imports inside `src/agent-ui/` (adapter boundary rule).
|
||||
4. Run the frontend build + adapter unit tests (frontend CI gate, #295).
|
||||
5. Do **not** add a `beautiful-ui` entry to `package.json` dependencies.
|
||||
@@ -0,0 +1,102 @@
|
||||
# ADR: BeautifulUI adapter layer & upstream pin policy
|
||||
|
||||
**Status:** Accepted · **Issue:** [#294](https://github.com/MervinPraison/PraisonAIUI/issues/294)
|
||||
|
||||
## Context
|
||||
|
||||
BeautifulUI is **not** a semver npm library — it is distributed as shadcn-style
|
||||
registry components that are copied into the consuming repo. PraisonAIUI must
|
||||
integrate it without leaking upstream types or import paths across the codebase,
|
||||
and without breaking when either side updates.
|
||||
|
||||
## Decision
|
||||
|
||||
Introduce a single **adapter boundary** between transport payloads and the UI,
|
||||
and vendor BeautifulUI components behind that boundary.
|
||||
|
||||
```
|
||||
Agent APIs (Python) ──► event bus / WS messages
|
||||
│
|
||||
▼
|
||||
Agent-UI adapter (src/agent-ui/adapter.ts)
|
||||
(stable PraisonAIUI contracts)
|
||||
│
|
||||
▼
|
||||
@praisonaiui/react re-exports
|
||||
(BeautifulUI vendored components)
|
||||
│
|
||||
▼
|
||||
Dashboard / chat mount points (React islands, then full shell)
|
||||
```
|
||||
|
||||
### 1. Folder layout
|
||||
|
||||
```
|
||||
src/frontend/src/agent-ui/
|
||||
├── contracts.ts # Stable PraisonAIUI view models (the ONLY types consumers import)
|
||||
├── adapter.ts # Pure mappers: wire payload -> contracts
|
||||
├── adapter.test.ts # Unit tests (mock events -> props), node:test
|
||||
├── index.ts # Barrel: contracts + adapters + (later) vendored re-exports
|
||||
└── vendors/
|
||||
└── beautifului/ # Vendored BeautifulUI components (added during migration)
|
||||
```
|
||||
|
||||
**Rule:** BeautifulUI / upstream prop types must **never** be imported outside
|
||||
`src/agent-ui/`. Consumers depend on `contracts.ts` only, so the vendored
|
||||
implementation can be swapped later without touching call sites.
|
||||
|
||||
### 2. Adapter contracts
|
||||
|
||||
Internal, transport-agnostic types live in `contracts.ts`:
|
||||
|
||||
- `AgentStreamEvent` — normalized form of the WebSocket frames handled in
|
||||
`chat.js` (`run_started`, `run_content`, `tool_call_started`, …). Unknown
|
||||
frame `type`s degrade to `kind: 'unknown'` with `rawType` preserved instead
|
||||
of throwing, so new upstream events never break the UI.
|
||||
- `ToolCallChipModel` — tool-call chip view model.
|
||||
- `ApprovalPromptModel` — approval card view model, mapped from the
|
||||
`/api/approvals/pending` REST payload.
|
||||
|
||||
Mappers (`adapter.ts`): `mapChatEventToUi(frame)` and
|
||||
`mapApprovalToCardProps(item)`. Both are **pure** and unit tested.
|
||||
|
||||
### 3. Vendoring & pin policy
|
||||
|
||||
| Mechanism | Purpose |
|
||||
|-----------|---------|
|
||||
| [`BEAUTIFULUI_UPSTREAM.md`](./BEAUTIFULUI_UPSTREAM.md) | Record git SHA, date, registry base URL for every vendored component |
|
||||
| shadcn registry URLs | Per-component install, pinned in the upstream doc |
|
||||
| **No** `dependencies.beautiful-ui` in `package.json` | Avoid a phantom npm package |
|
||||
|
||||
**Upgrade rule:** bump upstream **only** via a PR that updates
|
||||
`BEAUTIFULUI_UPSTREAM.md` and passes the frontend CI gate (issue #295). Icons
|
||||
stay on `lucide-react`; do not vendor paid Central icons.
|
||||
|
||||
### 4. Feature flag (default **off**)
|
||||
|
||||
Integration is gated so the existing vanilla-JS chat/approvals plugins remain
|
||||
the fallback until migration milestones (#296, #297) complete.
|
||||
|
||||
- **Flag:** `AIUI_BEAUTIFULUI` (build-time env / dashboard config JSON).
|
||||
- **Default:** off — vanilla JS renders; the adapter is imported but not mounted.
|
||||
- **On:** React islands mount using `src/agent-ui` contracts.
|
||||
- **Fallback:** if a vendored component fails to load, the flag is treated as
|
||||
off and the vanilla path renders. No user-facing regression when off.
|
||||
|
||||
### 5. Design tokens
|
||||
|
||||
Scope agent-UI primitives under an `.aiui-agent-ui { … }` CSS layer (or extend
|
||||
the existing shadcn presets in `themes.py`) so vendored component tokens cannot
|
||||
collide with the global dashboard theme.
|
||||
|
||||
### 6. Deprecation / upstream abandonment
|
||||
|
||||
Vendored code stays MIT-licensed in-tree. Because consumers depend only on the
|
||||
`contracts.ts` view models, a future first-party implementation can satisfy the
|
||||
same contracts and replace the vendored components with no call-site churn.
|
||||
|
||||
## Consequences
|
||||
|
||||
- One place (`src/agent-ui/`) owns the upstream coupling.
|
||||
- Adapters are testable without a browser or the transport layer.
|
||||
- The default-off flag keeps the migration reversible at every step.
|
||||
@@ -0,0 +1,107 @@
|
||||
# BeautifulUI Upgrade Playbook
|
||||
|
||||
How PraisonAIUI vendors [BeautifulUI](https://www.beautifului.dev/) React primitives so
|
||||
upgrades are **repeatable** and licence obligations are **explicit**.
|
||||
|
||||
!!! info "Related planning"
|
||||
Epic [#292](https://github.com/MervinPraison/PraisonAIUI/issues/292) (integration
|
||||
strategy) and the adapter/pin policy in
|
||||
[#294](https://github.com/MervinPraison/PraisonAIUI/issues/294).
|
||||
|
||||
## 1. What BeautifulUI is
|
||||
|
||||
BeautifulUI (MIT, © Shane Levine / Turbo) is a library of React primitives aimed at
|
||||
**AI-native interfaces** — thinking traces, streaming answers with inline sources,
|
||||
human-in-the-loop approval cards, tool chips, task rows, context chunks, and a reference
|
||||
chat harness.
|
||||
|
||||
**It is a shadcn registry + copy-paste library — not an npm package.** There is no
|
||||
published `beautiful-ui` package. You install components by resolving a registry URL, and
|
||||
the source is copied into your tree (like any other shadcn/ui primitive).
|
||||
|
||||
- Website & registry: <https://www.beautifului.dev/>
|
||||
- Upstream source: <https://github.com/slev12397/beautiful-ui>
|
||||
|
||||
Upstream stack (Next.js 15 · React 19 · Tailwind CSS v4 · TypeScript) is closely aligned
|
||||
with PraisonAIUI's `src/frontend` (Vite · React 19 · Tailwind v4 · shadcn/Radix), which is
|
||||
why copy-paste vendoring works without a framework shim.
|
||||
|
||||
## 2. One-time foundation setup
|
||||
|
||||
PraisonAIUI already carries the shadcn/ui foundation that BeautifulUI primitives depend on:
|
||||
|
||||
- **shadcn config** — `src/frontend/components.json` (`style: new-york`,
|
||||
`baseColor: neutral`, `iconLibrary: lucide`, `cssVariables: true`). Its `registries`
|
||||
map is where pinned BeautifulUI registry URLs are added (see §3).
|
||||
- **Theme tokens** — `src/praisonaiui/themes.py` generates the shadcn CSS variables
|
||||
(`--background`, `--foreground`, `--primary`, `--radius`, …) at build time for all 22
|
||||
official presets, offline. BeautifulUI's foundation CSS (upstream `app/globals.css`)
|
||||
must only contribute **new** tokens/utilities; it must **not** redefine variables that
|
||||
`themes.py` already owns, or theme switching and dark mode break.
|
||||
|
||||
**Rule:** when merging upstream `app/globals.css`, diff it against the variables emitted by
|
||||
`themes.py` and keep only the additive slices (new keyframes, component-scoped utilities).
|
||||
Record what was merged in the pin doc (§3).
|
||||
|
||||
## 3. Adding or updating a primitive
|
||||
|
||||
1. **Install with a pinned registry URL** from `src/frontend/`:
|
||||
```bash
|
||||
npx shadcn add https://www.beautifului.dev/r/<component>.json
|
||||
```
|
||||
The registry resolves dependencies and CSS slices. Commit the resulting
|
||||
`package-lock.json` change in the same PR.
|
||||
2. **Re-export through the adapter** — add the component to
|
||||
`src/frontend/src/components/index.ts` (`@praisonaiui/react`). Consumers never import
|
||||
the raw vendored path (see §4).
|
||||
3. **Update the pin record** — create/update `docs/frontend/BEAUTIFULUI_UPSTREAM.md` with:
|
||||
the upstream **git SHA**, the **date**, the **registry base URL**, and the **component
|
||||
list** vendored so far. This file is the single source of truth for "what version are
|
||||
we on". (It is created the first time a real primitive is vendored — do not add it
|
||||
empty.)
|
||||
4. **Rebuild & sync the bundle** — the committed frontend bundle must be regenerated:
|
||||
```bash
|
||||
cd src/frontend && npm run build
|
||||
# then sync dist/{index.html,assets/,icon.svg} → src/praisonaiui/templates/frontend/
|
||||
```
|
||||
(See `AGENTS.md` → "Frontend Build".)
|
||||
5. **Run frontend CI** — `.github/workflows/ci.yml` (ruff + pytest) must pass, plus the
|
||||
frontend build step.
|
||||
|
||||
## 4. Adapter boundary
|
||||
|
||||
Consumers import from **`@praisonaiui/react` only** — never from raw vendored paths such as
|
||||
`@/components/ui/...` or any upstream module.
|
||||
|
||||
```tsx
|
||||
// ✅ stable PraisonAIUI contract
|
||||
import { Button, ThemeToggle } from '@praisonaiui/react'
|
||||
|
||||
// ❌ leaks upstream/shadcn paths across the codebase
|
||||
import { Button } from '@/components/ui/button'
|
||||
```
|
||||
|
||||
This keeps the upstream surface behind one file (`components/index.ts`), so a BeautifulUI
|
||||
bump changes vendored source but not consumer imports.
|
||||
|
||||
## 5. Rollback
|
||||
|
||||
- BeautifulUI-backed views mount behind a **feature flag**; the **vanilla dashboard views**
|
||||
(`src/praisonaiui/templates/frontend/plugins/*`) remain the fallback and are kept working.
|
||||
- To roll back a bad bump: revert the vendoring PR (source + `BEAUTIFULUI_UPSTREAM.md` +
|
||||
rebuilt bundle move together in one commit), and disable the flag. No consumer import
|
||||
changes are needed because everything routes through the adapter (§4).
|
||||
|
||||
## 6. Licence
|
||||
|
||||
BeautifulUI is **MIT, © Shane Levine**. The full licence text is retained in
|
||||
[`THIRD_PARTY_NOTICES.md`](https://github.com/MervinPraison/PraisonAIUI/blob/main/THIRD_PARTY_NOTICES.md)
|
||||
at the repository root. Any PR that vendors or bumps BeautifulUI source must keep that
|
||||
notice present and current.
|
||||
|
||||
## 7. Commercial icons — do not vendor
|
||||
|
||||
Do **not** use `@central-icons-react` without a licence — it is commercial. PraisonAIUI
|
||||
standardises on **`lucide-react`** (`iconLibrary: lucide` in `components.json`). If an
|
||||
upstream BeautifulUI primitive imports commercial icons, swap them for lucide equivalents
|
||||
during vendoring.
|
||||
@@ -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/).
|
||||
@@ -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)
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Standalone ElevenLabs Speech Engine WebSocket server (port 8002).
|
||||
|
||||
ElevenLabs connects here for transcripts; the dashboard stays on 8001.
|
||||
Official SDK path: engine.serve() — more reliable than in-app Starlette /ws.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent
|
||||
if str(_ROOT) not in __import__("sys").path:
|
||||
__import__("sys").path.insert(0, str(_ROOT))
|
||||
|
||||
|
||||
def _load_env() -> None:
|
||||
env_path = _ROOT / ".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
|
||||
|
||||
|
||||
async def _main() -> None:
|
||||
_load_env()
|
||||
api_key = os.getenv("ELEVENLABS_API_KEY", "").strip()
|
||||
engine_id = os.getenv("SPEECH_ENGINE_ID", "").strip()
|
||||
port = int(os.getenv("SPEECH_ENGINE_PORT", "8002"))
|
||||
disable_auth = os.getenv("SPEECH_ENGINE_DISABLE_AUTH", "").lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
)
|
||||
|
||||
if not api_key or not engine_id:
|
||||
raise SystemExit("Set ELEVENLABS_API_KEY and SPEECH_ENGINE_ID in .env")
|
||||
|
||||
from elevenlabs import AsyncElevenLabs
|
||||
|
||||
from integrations.voice.speech_engine import (
|
||||
handle_session_close,
|
||||
handle_session_disconnect,
|
||||
handle_session_init,
|
||||
handle_user_transcript,
|
||||
)
|
||||
|
||||
client = AsyncElevenLabs(api_key=api_key)
|
||||
engine = await client.speech_engine.get(engine_id)
|
||||
|
||||
async def on_transcript(transcript, session): # noqa: ANN001
|
||||
await handle_user_transcript(transcript, session)
|
||||
|
||||
async def on_init(conversation_id, session): # noqa: ANN001
|
||||
await handle_session_init(conversation_id, session)
|
||||
|
||||
async def on_close(session): # noqa: ANN001
|
||||
await handle_session_close(session)
|
||||
|
||||
async def on_disconnect(session): # noqa: ANN001
|
||||
await handle_session_disconnect(session)
|
||||
|
||||
async def on_error(err, session): # noqa: ANN001
|
||||
print(f"[speech sidecar] error: {err}", flush=True)
|
||||
|
||||
debug = os.getenv("SPEECH_ENGINE_DEBUG", "true").lower() in ("1", "true", "yes")
|
||||
print(f"[speech sidecar] listening on ws://127.0.0.1:{port}/ws (debug={debug})", flush=True)
|
||||
await engine.serve(
|
||||
port=port,
|
||||
path="/ws",
|
||||
debug=debug,
|
||||
disable_auth=disable_auth,
|
||||
on_init=on_init,
|
||||
on_transcript=on_transcript,
|
||||
on_close=on_close,
|
||||
on_disconnect=on_disconnect,
|
||||
on_error=on_error,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(_main())
|
||||
@@ -0,0 +1,428 @@
|
||||
# Start voice-agent + Cloudflare tunnel for provider webhook URL.
|
||||
# Usage: .\start_dev.ps1
|
||||
# .\start_dev.ps1 -Restart # kill old app and reload code
|
||||
|
||||
param(
|
||||
[int]$Port = 8001,
|
||||
[switch]$Restart
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
Set-Location $Root
|
||||
|
||||
function Load-DotEnv {
|
||||
$envFile = Join-Path $Root ".env"
|
||||
if (-not (Test-Path $envFile)) { return }
|
||||
Get-Content $envFile | ForEach-Object {
|
||||
$line = $_.Trim()
|
||||
if (-not $line -or $line.StartsWith("#") -or $line -notmatch "=") { return }
|
||||
$key, $value = $line -split "=", 2
|
||||
$key = $key.Trim()
|
||||
if ($key -and -not [string]::IsNullOrWhiteSpace($key)) {
|
||||
Set-Item -Path "Env:$key" -Value $value.Trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Test-AppHttpReady([int]$p) {
|
||||
foreach ($path in @("/health/live", "/health", "/api/voice/config")) {
|
||||
try {
|
||||
$r = Invoke-WebRequest -Uri "http://127.0.0.1:$p$path" -UseBasicParsing -TimeoutSec 15
|
||||
if ($r.StatusCode -eq 200) { return $true }
|
||||
} catch { continue }
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Test-TcpPortListening([int]$p) {
|
||||
try {
|
||||
$conn = Get-NetTCPConnection -LocalPort $p -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($conn) { return $true }
|
||||
} catch { }
|
||||
try {
|
||||
$client = New-Object System.Net.Sockets.TcpClient
|
||||
$async = $client.BeginConnect("127.0.0.1", $p, $null, $null)
|
||||
$ok = $async.AsyncWaitHandle.WaitOne(500)
|
||||
if ($ok -and $client.Connected) {
|
||||
$client.Close()
|
||||
return $true
|
||||
}
|
||||
$client.Close()
|
||||
} catch { }
|
||||
return $false
|
||||
}
|
||||
|
||||
function Wait-PortReleased([int]$p, [int]$maxSec = 20) {
|
||||
$deadline = (Get-Date).AddSeconds($maxSec)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
if (-not (Test-TcpPortListening $p)) { return $true }
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
return -not (Test-TcpPortListening $p)
|
||||
}
|
||||
|
||||
function Stop-PortProcess([int]$p) {
|
||||
try {
|
||||
$conns = Get-NetTCPConnection -LocalPort $p -State Listen -ErrorAction SilentlyContinue
|
||||
foreach ($conn in $conns) {
|
||||
if ($conn.OwningProcess) {
|
||||
Stop-Process -Id $conn.OwningProcess -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
if ($conns) {
|
||||
Start-Sleep -Seconds 2
|
||||
Write-Host "Stopped previous process on port $p"
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
function Clear-LogFile([string]$path) {
|
||||
if (-not (Test-Path $path)) { return }
|
||||
try {
|
||||
Remove-Item $path -Force -ErrorAction Stop
|
||||
} catch {
|
||||
try { Clear-Content $path -ErrorAction SilentlyContinue } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
function Start-VoiceApp([int]$p) {
|
||||
Write-Host "Starting voice-agent on port $p..."
|
||||
Stop-PortProcess $p
|
||||
$appLog = Join-Path $Root ".app.log"
|
||||
$appErr = Join-Path $Root ".app.err"
|
||||
Clear-LogFile $appLog
|
||||
Clear-LogFile $appErr
|
||||
Start-Process -FilePath "py" -ArgumentList "-3.13", "app.py" -WorkingDirectory $Root -WindowStyle Minimized `
|
||||
-RedirectStandardOutput $appLog -RedirectStandardError $appErr
|
||||
$deadline = (Get-Date).AddSeconds(120)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
if (Test-AppHttpReady $p) { break }
|
||||
Start-Sleep -Seconds 3
|
||||
}
|
||||
if (-not (Test-AppHttpReady $p)) {
|
||||
$errText = ""
|
||||
if (Test-Path $appErr) { $errText = Get-Content $appErr -Raw -ErrorAction SilentlyContinue }
|
||||
if ($errText -match "Uvicorn running") {
|
||||
Write-Host "Server process up — waiting for HTTP endpoints..."
|
||||
Start-Sleep -Seconds 10
|
||||
}
|
||||
}
|
||||
if (-not (Test-AppHttpReady $p)) {
|
||||
if (Test-Path $appErr) {
|
||||
Write-Host "App startup log:" -ForegroundColor Red
|
||||
Get-Content $appErr -Tail 20 | ForEach-Object { Write-Host $_ }
|
||||
}
|
||||
throw "App did not become ready on http://127.0.0.1:$p"
|
||||
}
|
||||
Write-Host "App ready: http://127.0.0.1:$p"
|
||||
}
|
||||
|
||||
function Get-CloudflaredPidFile([string]$suffix) {
|
||||
return Join-Path $Root ".cloudflared$suffix.pid"
|
||||
}
|
||||
|
||||
function Stop-CloudflaredForSuffix([string]$suffix) {
|
||||
$pidFile = Get-CloudflaredPidFile $suffix
|
||||
try {
|
||||
if (Test-Path $pidFile) {
|
||||
$procId = [int](Get-Content $pidFile -Raw).Trim()
|
||||
if ($procId) {
|
||||
Stop-Process -Id $procId -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Remove-Item $pidFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Start-Sleep -Seconds 1
|
||||
} catch { }
|
||||
}
|
||||
|
||||
function Stop-CloudflaredTunnel {
|
||||
Stop-CloudflaredForSuffix ""
|
||||
}
|
||||
|
||||
function Test-TunnelUrl([string]$baseUrl, [int]$retries = 2) {
|
||||
if (-not $baseUrl) { return $false }
|
||||
for ($i = 0; $i -lt $retries; $i++) {
|
||||
try {
|
||||
$null = Invoke-WebRequest -Uri "$baseUrl/api/voice/config" -UseBasicParsing -TimeoutSec 8
|
||||
return $true
|
||||
} catch {
|
||||
if ($i -lt ($retries - 1)) { Start-Sleep -Seconds 2 }
|
||||
}
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Test-SpeechTunnelUrl([string]$baseUrl, [int]$retries = 12) {
|
||||
if (-not $baseUrl) { return $false }
|
||||
$paths = @("", "/ws")
|
||||
for ($i = 0; $i -lt $retries; $i++) {
|
||||
foreach ($path in $paths) {
|
||||
$uri = if ($path) { "$baseUrl$path" } else { $baseUrl }
|
||||
try {
|
||||
$null = Invoke-WebRequest -Uri $uri -UseBasicParsing -TimeoutSec 10
|
||||
return $true
|
||||
} catch {
|
||||
$code = $null
|
||||
try {
|
||||
if ($_.Exception.Response) {
|
||||
$code = [int]$_.Exception.Response.StatusCode
|
||||
}
|
||||
} catch { }
|
||||
# Sidecar returns 404 on / — that still means the tunnel is up.
|
||||
if ($code -and $code -ge 200 -and $code -lt 500) {
|
||||
return $true
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($i -lt ($retries - 1)) {
|
||||
Write-Host " Speech tunnel not ready yet (attempt $($i + 1)/$retries)..."
|
||||
Start-Sleep -Seconds 3
|
||||
}
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Update-EnvValue([string]$key, [string]$value) {
|
||||
$envFile = Join-Path $Root ".env"
|
||||
if (-not (Test-Path $envFile)) { return }
|
||||
$lines = Get-Content $envFile
|
||||
$updated = $false
|
||||
$newLines = foreach ($line in $lines) {
|
||||
if ($line -match "^\s*$key=") {
|
||||
$updated = $true
|
||||
"$key=$value"
|
||||
} else { $line }
|
||||
}
|
||||
if (-not $updated) { $newLines += "$key=$value" }
|
||||
Set-Content -Path $envFile -Value ($newLines -join "`n")
|
||||
Set-Item -Path "Env:$key" -Value $value
|
||||
}
|
||||
|
||||
function Start-SpeechSidecar([int]$p = 8002, [switch]$Force) {
|
||||
if ((Test-TcpPortListening $p) -and -not $Force) {
|
||||
Write-Host "Speech Engine sidecar already listening on port $p"
|
||||
return
|
||||
}
|
||||
Write-Host "Starting Speech Engine sidecar on port $p..."
|
||||
Stop-PortProcess $p
|
||||
if (-not (Wait-PortReleased $p)) {
|
||||
Write-Host "Port $p still in use — forcing stop again..." -ForegroundColor Yellow
|
||||
Stop-PortProcess $p
|
||||
Start-Sleep -Seconds 3
|
||||
}
|
||||
$log = Join-Path $Root ".speech-sidecar.log"
|
||||
$logErr = "${log}.err"
|
||||
Clear-LogFile $log
|
||||
Clear-LogFile $logErr
|
||||
$env:PYTHONUNBUFFERED = "1"
|
||||
Start-Process -FilePath "py" -ArgumentList "-3.13", "-u", "speech_engine_sidecar.py" `
|
||||
-WorkingDirectory $Root -WindowStyle Minimized `
|
||||
-RedirectStandardOutput $log -RedirectStandardError $logErr
|
||||
$deadline = (Get-Date).AddSeconds(90)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
if (Test-TcpPortListening $p) {
|
||||
Write-Host "Speech Engine sidecar ready on port $p"
|
||||
return
|
||||
}
|
||||
if (Test-Path $log) {
|
||||
$tail = Get-Content $log -Tail 1 -ErrorAction SilentlyContinue
|
||||
if ($tail) { Write-Host " $tail" }
|
||||
}
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
Write-Host "Sidecar logs:" -ForegroundColor Red
|
||||
if (Test-Path $log) {
|
||||
Get-Content $log -Tail 15 -ErrorAction SilentlyContinue | ForEach-Object { Write-Host $_ }
|
||||
}
|
||||
if (Test-Path $logErr) {
|
||||
Get-Content $logErr -Tail 15 -ErrorAction SilentlyContinue | ForEach-Object { Write-Host $_ }
|
||||
}
|
||||
throw "Speech Engine sidecar did not start on port $p (ElevenLabs API fetch can take up to 90s — check .speech-sidecar.log)"
|
||||
}
|
||||
|
||||
function Start-CloudflaredTunnel([int]$p, [string]$logSuffix = "") {
|
||||
Write-Host "Starting Cloudflare tunnel for port $p..."
|
||||
$log = Join-Path $Root ".tunnel$logSuffix.log"
|
||||
$logErr = "${log}.err"
|
||||
Stop-CloudflaredForSuffix $logSuffix
|
||||
Clear-LogFile $log
|
||||
Clear-LogFile $logErr
|
||||
$proc = Start-Process -FilePath "cloudflared" -ArgumentList "tunnel", "--url", "http://127.0.0.1:$p" `
|
||||
-RedirectStandardOutput $log -RedirectStandardError $logErr -WindowStyle Minimized -PassThru
|
||||
if ($proc -and $proc.Id) {
|
||||
Set-Content -Path (Get-CloudflaredPidFile $logSuffix) -Value $proc.Id
|
||||
}
|
||||
$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]+(-[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" }
|
||||
if ($logSuffix) {
|
||||
Write-Host "Tunnel ready ($logSuffix): $newUrl"
|
||||
} else {
|
||||
Update-PublicUrl $newUrl
|
||||
Write-Host "Tunnel ready: $newUrl"
|
||||
}
|
||||
return $newUrl
|
||||
}
|
||||
|
||||
function Update-PublicUrl([string]$baseUrl) {
|
||||
$envFile = Join-Path $Root ".env"
|
||||
$content = Get-Content $envFile -Raw
|
||||
if ($content -match "PUBLIC_API_BASE_URL=.*") {
|
||||
$newContent = $content -replace "PUBLIC_API_BASE_URL=.*", "PUBLIC_API_BASE_URL=$baseUrl"
|
||||
} else {
|
||||
$newContent = $content.TrimEnd() + "`nPUBLIC_API_BASE_URL=$baseUrl`n"
|
||||
}
|
||||
Set-Content -Path $envFile -Value $newContent -NoNewline
|
||||
$env:PUBLIC_API_BASE_URL = $baseUrl
|
||||
Write-Host "Updated .env PUBLIC_API_BASE_URL=$baseUrl"
|
||||
}
|
||||
|
||||
function Ensure-ServerSecret {
|
||||
$envFile = Join-Path $Root ".env"
|
||||
if (-not (Test-Path $envFile)) { return }
|
||||
$lines = Get-Content $envFile
|
||||
$secret = $env:VOICE_SERVER_URL_SECRET
|
||||
foreach ($line in $lines) {
|
||||
if ($line -match '^\s*VOICE_SERVER_URL_SECRET=(.+)$') {
|
||||
$secret = $Matches[1].Trim()
|
||||
break
|
||||
}
|
||||
}
|
||||
if (-not $secret) {
|
||||
$secret = [guid]::NewGuid().ToString()
|
||||
$updated = $false
|
||||
$newLines = foreach ($line in $lines) {
|
||||
if ($line -match '^\s*VOICE_SERVER_URL_SECRET=') {
|
||||
$updated = $true
|
||||
"VOICE_SERVER_URL_SECRET=$secret"
|
||||
} else { $line }
|
||||
}
|
||||
if (-not $updated) { $newLines += "VOICE_SERVER_URL_SECRET=$secret" }
|
||||
Set-Content -Path $envFile -Value ($newLines -join "`n")
|
||||
Write-Host "Generated VOICE_SERVER_URL_SECRET in .env"
|
||||
}
|
||||
$env:VOICE_SERVER_URL_SECRET = $secret
|
||||
}
|
||||
|
||||
function Show-ProviderSetupSteps([string]$webhookUrl, [string]$secret) {
|
||||
Write-Host ""
|
||||
Write-Host "=== Provider webhook setup (Assistant -> Advanced -> Webhook Server) ===" -ForegroundColor Cyan
|
||||
Write-Host "Webhook URL:"
|
||||
Write-Host " $webhookUrl"
|
||||
Write-Host ""
|
||||
Write-Host "Secret header:"
|
||||
Write-Host " Name: X-Voice-Webhook-Secret"
|
||||
Write-Host " Value: $secret"
|
||||
Write-Host "================================================================" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
Load-DotEnv
|
||||
Ensure-ServerSecret
|
||||
$env:VOICE_AGENT_PORT = "$Port"
|
||||
|
||||
$needsRestart = $Restart.IsPresent
|
||||
if (Test-TcpPortListening $Port) {
|
||||
if ($needsRestart) {
|
||||
Stop-PortProcess $Port
|
||||
} else {
|
||||
try {
|
||||
$cfg = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/api/voice/config" -TimeoutSec 3
|
||||
if (-not $cfg.webhook_url -and $env:PUBLIC_API_BASE_URL) {
|
||||
Write-Host "App running but missing webhook URL — restarting to reload .env"
|
||||
$needsRestart = $true
|
||||
Stop-PortProcess $Port
|
||||
} else {
|
||||
Write-Host "App already running on port $Port"
|
||||
}
|
||||
} catch {
|
||||
Write-Host "App already running on port $Port"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$public = $env:PUBLIC_API_BASE_URL
|
||||
$mainTunnelRecreated = $false
|
||||
if (Test-TunnelUrl $public) {
|
||||
Write-Host "Tunnel OK: $public"
|
||||
} else {
|
||||
$null = Start-CloudflaredTunnel $Port
|
||||
$mainTunnelRecreated = $true
|
||||
}
|
||||
if ($needsRestart -or -not (Test-TcpPortListening $Port)) {
|
||||
Start-VoiceApp $Port
|
||||
} elseif ($mainTunnelRecreated) {
|
||||
Write-Host "Main tunnel URL changed — restarting app to reload .env"
|
||||
Stop-PortProcess $Port
|
||||
Start-VoiceApp $Port
|
||||
}
|
||||
|
||||
$config = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/api/voice/config"
|
||||
Write-Host "Voice configured: $($config.configured)"
|
||||
Write-Host "Webhook URL: $($config.webhook_url)"
|
||||
$webhook = if ($config.webhook_url) { $config.webhook_url } else { "$($env:PUBLIC_API_BASE_URL)/webhooks/voice" }
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Configuring voice provider via API..." -ForegroundColor Cyan
|
||||
py -3.13 setup_voice_provider.py
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "Voice provider auto-configured (tools + webhook URL + auth)." -ForegroundColor Green
|
||||
} else {
|
||||
Show-ProviderSetupSteps -webhookUrl $webhook -secret $env:VOICE_SERVER_URL_SECRET
|
||||
}
|
||||
|
||||
if ($env:ELEVENLABS_API_KEY) {
|
||||
Write-Host ""
|
||||
$speechSidecarOk = Test-TcpPortListening 8002
|
||||
$speechTunnelOk = Test-SpeechTunnelUrl $env:SPEECH_ENGINE_PUBLIC_URL
|
||||
if (-not $speechTunnelOk) {
|
||||
Write-Host "Speech tunnel down — starting sidecar + new Cloudflare tunnel..." -ForegroundColor Cyan
|
||||
Start-SpeechSidecar 8002 -Force
|
||||
$speechTunnel = Start-CloudflaredTunnel 8002 "-speech"
|
||||
Update-EnvValue "SPEECH_ENGINE_PUBLIC_URL" $speechTunnel
|
||||
Write-Host "Speech Engine tunnel: $speechTunnel"
|
||||
Write-Host "Waiting for speech tunnel to accept connections..."
|
||||
Start-Sleep -Seconds 5
|
||||
Write-Host "Configuring ElevenLabs Speech Engine..." -ForegroundColor Cyan
|
||||
py -3.13 setup_speech_engine.py
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Speech Engine setup failed — run: py -3.13 setup_speech_engine.py"
|
||||
}
|
||||
Write-Host "Speech Engine configured (WebSocket /ws + browser token)." -ForegroundColor Green
|
||||
Load-DotEnv
|
||||
Stop-PortProcess $Port
|
||||
Start-VoiceApp $Port
|
||||
} elseif ($Restart.IsPresent -or -not $speechSidecarOk) {
|
||||
Write-Host "Restarting speech sidecar to reload code (keeping existing tunnel)..." -ForegroundColor Yellow
|
||||
Start-SpeechSidecar 8002 -Force
|
||||
} else {
|
||||
Write-Host "Speech stack OK (sidecar :8002 + tunnel reachable)" -ForegroundColor Green
|
||||
}
|
||||
if (-not (Test-TcpPortListening 8002)) {
|
||||
throw "Speech Engine sidecar not listening on 8002"
|
||||
}
|
||||
Load-DotEnv
|
||||
if (-not (Test-SpeechTunnelUrl $env:SPEECH_ENGINE_PUBLIC_URL)) {
|
||||
Write-Host ""
|
||||
Write-Host "WARNING: Speech Engine tunnel not reachable yet: $($env:SPEECH_ENGINE_PUBLIC_URL)" -ForegroundColor Yellow
|
||||
Write-Host "Local dashboard is up at http://127.0.0.1:$Port — wait ~30s, then retry ElevenLabs talk." -ForegroundColor Yellow
|
||||
Write-Host "If it still fails, run: .\start_dev.ps1 -Restart" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Speech sidecar: port 8002 | ws_url wss://$($env:SPEECH_ENGINE_PUBLIC_URL -replace 'https://','')/ws"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Done. Dashboard: http://127.0.0.1:$Port"
|
||||
Write-Host "ElevenLabs talk: http://127.0.0.1:$Port/eleven-talk"
|
||||
Write-Host "Keep this window open — sidecar + tunnels run in the background."
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Voice agent HTTP routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from integrations.voice.client import VoiceClient
|
||||
from integrations.voice.config import VoiceConfigError, get_web_sdk_settings, load_voice_settings
|
||||
from integrations.voice.live import transcript_hub
|
||||
from integrations.voice.processor import process_voice_webhook
|
||||
from integrations.voice.store import VoiceCallStore
|
||||
from integrations.voice.verify import VerificationError, verify_webhook_request
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, PlainTextResponse, Response, StreamingResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Hold strong references to background tasks so the event loop (which only keeps
|
||||
# weak references) cannot garbage-collect them mid-flight and drop a webhook event.
|
||||
_background_tasks: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
def _schedule_webhook_processing(event_type: str, payload: dict, *, settings) -> None:
|
||||
task = asyncio.create_task(
|
||||
asyncio.to_thread(process_voice_webhook, event_type, payload, settings=settings)
|
||||
)
|
||||
_background_tasks.add(task)
|
||||
task.add_done_callback(_background_tasks.discard)
|
||||
|
||||
|
||||
async def api_voice_config_status(_request: Request) -> JSONResponse:
|
||||
try:
|
||||
settings = load_voice_settings()
|
||||
configured = True
|
||||
error = None
|
||||
except VoiceConfigError as exc:
|
||||
settings = None
|
||||
configured = False
|
||||
error = str(exc)
|
||||
return JSONResponse(
|
||||
{
|
||||
"configured": configured,
|
||||
"error": error,
|
||||
"webhook_url": settings.webhook_url if settings else None,
|
||||
"default_assistant_id": settings.default_assistant_id if settings else None,
|
||||
"web_talk_enabled": bool(settings.public_api_key and settings.default_assistant_id),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def api_voice_web_config(_request: Request) -> JSONResponse:
|
||||
try:
|
||||
settings = load_voice_settings(require_key=False)
|
||||
except VoiceConfigError as exc:
|
||||
return JSONResponse({"enabled": False, "error": str(exc)})
|
||||
sdk_url, sdk_global = get_web_sdk_settings()
|
||||
return JSONResponse(
|
||||
{
|
||||
"enabled": bool(settings.public_api_key and settings.default_assistant_id and sdk_url),
|
||||
"public_key": settings.public_api_key,
|
||||
"assistant_id": settings.default_assistant_id,
|
||||
"sdk_url": sdk_url or None,
|
||||
"sdk_global": sdk_global or "Vapi",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def api_voice_create_call(request: Request) -> JSONResponse:
|
||||
try:
|
||||
body = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
return JSONResponse({"error": "invalid JSON body"}, status_code=400)
|
||||
number = str(body.get("customer_number") or body.get("phone_number") or "").strip()
|
||||
if not number:
|
||||
return JSONResponse({"error": "customer_number is required"}, status_code=400)
|
||||
try:
|
||||
settings = load_voice_settings()
|
||||
client = VoiceClient(settings)
|
||||
result = await asyncio.to_thread(
|
||||
client.create_call,
|
||||
customer_number=number,
|
||||
assistant_id=body.get("assistant_id"),
|
||||
phone_number_id=body.get("phone_number_id"),
|
||||
metadata=body.get("metadata") if isinstance(body.get("metadata"), dict) else None,
|
||||
)
|
||||
except VoiceConfigError as exc:
|
||||
return JSONResponse({"error": str(exc)}, status_code=503)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return JSONResponse({"error": str(exc)}, status_code=502)
|
||||
call_id = result.get("id")
|
||||
if call_id:
|
||||
VoiceCallStore.upsert_call(str(call_id), status="scheduled", customer_number=number)
|
||||
return JSONResponse(result, status_code=201)
|
||||
|
||||
|
||||
async def api_voice_list_calls(_request: Request) -> JSONResponse:
|
||||
from integrations.voice.call_finalize import analytics_for_record
|
||||
|
||||
calls = []
|
||||
for record in VoiceCallStore.list_calls():
|
||||
row = dict(record)
|
||||
row["analytics"] = analytics_for_record(record)
|
||||
calls.append(row)
|
||||
return JSONResponse({"calls": calls})
|
||||
|
||||
|
||||
async def api_voice_doctor(_request: Request) -> JSONResponse:
|
||||
import os
|
||||
|
||||
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"))
|
||||
return JSONResponse(run_voice_doctor(app_port=app_port, sidecar_port=sidecar_port))
|
||||
|
||||
|
||||
async def api_voice_call_detail(request: Request) -> JSONResponse:
|
||||
from integrations.voice.call_finalize import analytics_for_record
|
||||
|
||||
call_id = request.path_params["call_id"]
|
||||
record = VoiceCallStore.get_call(call_id)
|
||||
if not record:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
out = dict(record)
|
||||
out["analytics"] = analytics_for_record(record)
|
||||
return JSONResponse(out)
|
||||
|
||||
|
||||
async def api_voice_call_detail_ui(request: Request) -> JSONResponse:
|
||||
from integrations.voice.call_detail_ui import build_call_detail_layout
|
||||
|
||||
call_id = request.path_params["call_id"]
|
||||
record = VoiceCallStore.get_call(call_id)
|
||||
if not record:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
layout = build_call_detail_layout(record)
|
||||
return JSONResponse({"call_id": call_id, "layout": layout})
|
||||
|
||||
|
||||
async def api_voice_live_transcript(request: Request) -> StreamingResponse:
|
||||
call_id = request.path_params["call_id"]
|
||||
|
||||
async def event_stream():
|
||||
record = VoiceCallStore.get_call(call_id)
|
||||
if record and record.get("transcript"):
|
||||
yield transcript_hub.snapshot_sse(
|
||||
{"type": "snapshot", "transcript": record["transcript"]}
|
||||
)
|
||||
async for event in transcript_hub.subscribe(call_id):
|
||||
yield transcript_hub.snapshot_sse(event)
|
||||
|
||||
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
||||
|
||||
|
||||
async def webhook_voice(request: Request) -> Response:
|
||||
raw = (await request.body()).decode("utf-8")
|
||||
try:
|
||||
settings = load_voice_settings(require_key=False)
|
||||
except VoiceConfigError as exc:
|
||||
return JSONResponse({"error": str(exc)}, status_code=503)
|
||||
|
||||
headers = {k: v for k, v in request.headers.items()}
|
||||
try:
|
||||
verify_webhook_request(secret=settings.server_url_secret, headers=headers)
|
||||
except VerificationError:
|
||||
return JSONResponse({"error": "invalid secret"}, status_code=401)
|
||||
|
||||
try:
|
||||
payload = json.loads(raw) if raw else {}
|
||||
except json.JSONDecodeError:
|
||||
return JSONResponse({"error": "invalid JSON"}, status_code=400)
|
||||
|
||||
msg = payload.get("message") if isinstance(payload.get("message"), dict) else payload
|
||||
event_type = str((msg or {}).get("type") or payload.get("type") or "unknown")
|
||||
|
||||
if event_type in ("tool-calls", "assistant-request", "transfer-destination-request", "knowledge-base-request"):
|
||||
result = await asyncio.to_thread(process_voice_webhook, event_type, payload, settings=settings)
|
||||
return JSONResponse(result or {})
|
||||
|
||||
_schedule_webhook_processing(event_type, payload, settings=settings)
|
||||
return PlainTextResponse("ok", status_code=200)
|
||||
@@ -95,6 +95,9 @@ nav:
|
||||
- Templates & Layouts: concepts/templates.md
|
||||
- Slots & Components: concepts/slots.md
|
||||
- Providers: concepts/providers.md
|
||||
- Frontend:
|
||||
- BeautifulUI Adapter: frontend/beautifului-adapter-architecture.md
|
||||
- BeautifulUI Upstream Pin: frontend/BEAUTIFULUI_UPSTREAM.md
|
||||
- Features:
|
||||
- Dashboard: features/dashboard.md
|
||||
- Gateway Chat: features/gateway-chat.md
|
||||
@@ -129,12 +132,15 @@ nav:
|
||||
- Feature Inventory: features/feature-inventory.md
|
||||
- Gap Analysis: features/gap-analysis.md
|
||||
- Integration Analysis: features/integration-analysis.md
|
||||
- BeautifulUI Integration: features/beautifului-integration.md
|
||||
- Standardization: features/standardization-verification.md
|
||||
- Comparison (OpenClaw): features/comparison-openclaw.md
|
||||
- What Makes It Different: features/differentiation.md
|
||||
- How to Add a Feature: features/how-to-add-a-feature.md
|
||||
- Message Elements: features/message-elements.md
|
||||
- Steps: features/steps.md
|
||||
- Frontend:
|
||||
- BeautifulUI Playbook: frontend/beautifului.md
|
||||
- API Reference:
|
||||
- Python CLI: api/cli.md
|
||||
- Python API: api/python.md
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "aiui"
|
||||
version = "0.3.137"
|
||||
version = "0.3.147"
|
||||
description = "YAML-driven website generator - CLI and compiler"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
@@ -11,9 +11,7 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<title>Documentation</title>
|
||||
<script>
|
||||
// Anti-flicker: apply dark mode class before first paint
|
||||
// Default to dark to prevent flash; React will correct if needed
|
||||
document.documentElement.classList.add('dark');
|
||||
(function(){try{var t=localStorage.getItem('aiui-theme-preference');var d=__AIUI_DARK_DEFAULT__;if(t==='dark'||(t==='system'&&matchMedia('(prefers-color-scheme: dark)').matches)||(!t&&d))document.documentElement.classList.add('dark');else if(t==='light')document.documentElement.classList.remove('dark');}catch(e){}})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="/assets/theme.css">
|
||||
</head>
|
||||
|
||||
Generated
+903
-2
File diff suppressed because it is too large.
Load diff
Loaded 100 of 259 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user