Compare commits

...
Author SHA1 Message Date
praisonai-triage-agent[bot]andMervin Praison a44776b18e fix: surface AC-4 opt-in on cross-origin CORS block in gateway probe
The reachability probe used default cors mode, so a cross-origin CORS
block rejected the fetch (landing in the 'network' branch) instead of
producing an opaque response. That made the 'opaque' branch and its
"Continue anyway" checkbox (AC-4) dead code. Now a rejected fetch on a
cross-origin URL maps to 'opaque' (ambiguous: CORS-blocked or down) so
the opt-in checkbox renders, while same-origin rejections remain a
definitive 'network' error.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-31 10:56:40 +00:00
praisonai-triage-agent[bot]andMervin Praison 92adfc6f6e fix: honest gateway reachability probe on onboarding Screen 1 (fixes #210)
Co-authored-by: Mervin Praison <[email protected]>
2026-07-30 06:32:04 +00:00
MervinPraison 90315326f7 Release v0.3.138 2026-07-30 01:56:27 +00:00
MervinPraison 0719c41399 fix: auto-trigger conflict and CI fixes without FINAL review gate
Remove the FINAL @claude prerequisite from merge-conflict and CI-failure
workflows, clear pipeline/blocked:conflict after rebase completion, add
bot-pr-recovery cron, and update gate selftests.
2026-07-29 18:15:42 +01:00
praisonai-triage-agent[bot] d008b6a250 Merge pull request #216 from MervinPraison/claude/issue-208-20260714-i18n-memory
feat: wire i18n strings and per-turn memory search into context ring UI
2026-07-29 14:38:03 +00:00
MervinPraisonandClaude 631ae74a29 feat: wire i18n strings and per-turn memory search into context ring UI (fixes #208)
Gap A: chat.js now loads locale strings via /api/i18n and renders ring
tooltip, compaction banner, compacted flash, and memory chip through a
t() helper with English fallback.

Gap B: refreshMemoryChip() posts the last user message to
/api/memory/search and shows per-turn hit count instead of the total
stored memory count from GET /api/memory.

Gap C: role="progressbar" and aria-value* moved to the ring wrapper div;
the send button is now type="button" with a send-only label.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-29 13:05:17 +00:00
praisonai-triage-agent[bot] e85f6778dd Merge pull request #258 from MervinPraison/claude/issue-256-20260729-120000
fix: stop full-suite pollution flaking auth timing + health cache tests
2026-07-29 11:39:19 +00:00
praisonai-triage-agent[bot] d6354fade0 Merge pull request #257 from MervinPraison/claude/issue-255-20260729-000000
fix: Windows-safe test_default_sqlite_url (stub HOME/USERPROFILE)
2026-07-29 11:35:43 +00:00
MervinPraisonandClaude b16a5f6056 fix: stop full-suite pollution flaking auth timing + health cache tests (fixes #256)
Reset the global _features registry in the test_server client fixture so a
feature leaked by an earlier crashing test cannot slow /health and flake the
deep-health cache/timing assertions. Make test_timing_attack_resistance
noise-resistant by comparing the median of repeated samples with a warm-up
instead of a single sample, so CPU contention under the full suite no longer
trips the variance threshold.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-29 09:28:32 +00:00
MervinPraisonandClaude 6521beb7b2 fix: stub HOME/USERPROFILE in test_default_sqlite_url for Windows (fixes #255)
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-29 09:21:35 +00:00
MervinPraison 9e3384c40a Release v0.3.137 2026-07-29 02:06:56 +00:00
praisonai-triage-agent[bot] e12416e15b Merge pull request #254 from MervinPraison/claude/issue-252-20260728-000000
fix: make five unit tests pass on Windows
2026-07-28 11:38:05 +00:00
MervinPraison 5568b10077 Release v0.3.136 2026-07-28 11:33:51 +00:00
praisonai-triage-agent[bot] 557852b287 Merge pull request #253 from MervinPraison/claude/issue-251-20260728-000000
fix: make URL-token auth opt-in so env token no longer forces 401
2026-07-28 11:30:52 +00:00
MervinPraisonandClaude 652d612d35 fix: make five unit tests pass on Windows (fixes #252)
Resolve cross-platform failures caused by Windows' coarse time.time()
resolution, backslash path separators, and reliance on the grep binary.

- lifecycle: measure hook durations with time.perf_counter() so
  startup_time/shutdown_time are non-zero on Windows' low-res wall clock
- datastore: build the default sqlite URL with Path.as_posix() for a
  consistent forward-slash URL across platforms
- test_audio_hooks: allow last_chunk_at == started_at (clock granularity)
- test_sqlalchemy_datastore: compare against as_posix() paths
- test_unified_naming: replace grep subprocess with a portable Python scan

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-28 10:17:52 +00:00
MervinPraisonandClaude 1f578a4827 fix: make URL-token auth opt-in so env token no longer forces 401 (fixes #251)
TokenQueryMiddleware previously activated whenever GATEWAY_AUTH_TOKEN or
AIUI_URL_TOKEN was present in the process environment, forcing 401 on all
standalone /api/* routes. A token leaking into a developer shell after a
gateway run broke ad-hoc TestClient scripts (#238, #251).

Activation now requires explicit opt-in: AIUI_REQUIRE_TOKEN, YAML
auth.requireToken, or require_auth=True. Secured deployments keep full
Bearer / query-param / cookie token auth.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-28 10:16:03 +00:00
praisonai-triage-agent[bot] 84ebdbc695 Merge pull request #247 from MervinPraison/claude/issue-236-20260727-0911
fix: add UTF-8 encoding to frontend source test reads
2026-07-27 12:23:34 +00:00
praisonai-triage-agent[bot] 182d065524 Merge pull request #246 from MervinPraison/claude/issue-239-20260727-120000
fix: correct tracing integration test paths to /api/traces
2026-07-27 12:18:36 +00:00
praisonai-triage-agent[bot] 132018d123 Merge pull request #245 from MervinPraison/claude/issue-237-20260727-0911
fix: use explicit UTF-8 encoding in CLI integration test fixture
2026-07-27 12:13:41 +00:00
MervinPraison bb85860a33 Release v0.3.135 2026-07-27 12:11:31 +00:00
praisonai-triage-agent[bot] c074d0782c Merge pull request #244 from MervinPraison/claude/issue-234-20260727-fix
feat: configurable embedding model + degraded knowledge search reporting
2026-07-27 12:08:27 +00:00
praisonai-triage-agent[bot] f7e19b9a13 Merge pull request #243 from MervinPraison/claude/issue-233-20260727-0904
fix: correct tracing API test paths (/api/tracing -> /api/traces)
2026-07-27 12:02:58 +00:00
praisonai-triage-agent[bot] 1a91f574b9 Merge pull request #242 from MervinPraison/claude/issue-231-20260727-0857
feat: safe_print for examples to avoid cp1252 UnicodeEncodeError
2026-07-27 11:58:32 +00:00
praisonai-triage-agent[bot] 0c982f4959 Merge pull request #241 from MervinPraison/claude/issue-232-20260727-0900
fix: specify UTF-8 encoding for frontend source reads in tests
2026-07-27 11:53:17 +00:00
MervinPraisonandClaude 0cc7f70bbb fix: correct tracing integration test paths to /api/traces (fixes #239)
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-27 09:14:24 +00:00
praisonai-triage-agent[bot]andpraisonai-triage-agent[bot] 87c9ed09b4 fix: add UTF-8 encoding to frontend source test reads (fixes #236)
Standardize Path.read_text() calls across unit/integration tests to
explicitly use encoding="utf-8". On Windows the default locale is cp1252,
which raises UnicodeDecodeError when reading committed UTF-8 frontend
bundles (em dash, emoji, multi-byte chars), producing false negatives.

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
2026-07-27 09:14:23 +00:00
praisonai-triage-agent[bot]andMervin Praison 30c34e7266 fix: use explicit UTF-8 encoding in CLI integration test fixture (fixes #237)
Test fixture wrote YAML/markdown and read JSON without encoding, defaulting
to cp1252 on Windows and breaking UTF-8 reads for the © character. Specify
encoding="utf-8" on all write_text/read_text calls for deterministic
cross-platform behaviour.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 09:13:28 +00:00
MervinPraisonandClaude 43b9fe7253 feat: configurable embedding model + degraded-mode reporting for knowledge search (fixes #234)
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-27 09:10:15 +00:00
praisonai-triage-agent[bot]andMervin Praison 5b61ece40d fix: correct tracing API test paths /api/tracing to /api/traces (fixes #233)
Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 09:05:29 +00:00
praisonai-triage-agent[bot]andMervin Praison 6fd27d0ed7 feat: safe_print for examples to avoid cp1252 UnicodeEncodeError (fixes #231)
Add examples/python/_shared/console.py with safe_print/icon helpers that
degrade emoji to ASCII on legacy Windows code pages (cp1252) instead of
crashing, mirroring the CLI _supports_unicode/_icon pattern. Wire the helper
into the high-severity gateway (16, 17, 18) and channel (20-23) examples so
they start on default Windows PowerShell without PYTHONUTF8.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 09:04:48 +00:00
praisonai-triage-agent[bot]andMervin Praison 78a8a499c1 fix: specify UTF-8 encoding for frontend source reads in tests (fixes #232)
Standardize all Path.read_text() calls in the test suite to pass
encoding="utf-8" so committed UTF-8 frontend JS/HTML bundles decode
correctly on Windows (cp1252 default locale) instead of raising
UnicodeDecodeError.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 09:03:59 +00:00
55 changed files with 1301 additions and 362 deletions

No files matched your search

@@ -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);
+1 -4
View File
@@ -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' };
@@ -221,7 +220,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 +227,6 @@ async function maybeTriggerCiFixClaude(github, owner, repo, prNumber, core, opts
comments: ctx.comments,
headSha,
labels,
hasFinal,
claudeInProgress,
failedChecks: failedRuns,
});
+1 -1
View File
@@ -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
));
+1 -1
View File
@@ -113,7 +113,7 @@ function conflictRebaseQuiescent(comments, headPushedAt) {
);
if (!rebaseDone) return false;
return finalClaudeCompletedOnSha(comments, headPushedAt);
return true;
}
function hasRecentConflictComment(comments, headPushedAt = null) {
+43
View File
@@ -0,0 +1,43 @@
name: Bot PR Recovery
# Belt-and-braces: kick CodeRabbit/Qodo when pull_request:opened never fired for bot PRs.
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:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Find and kick stalled bot PRs
uses: actions/github-script@v7
env:
PR_NUMBER_INPUT: ${{ github.event.inputs.pr_number }}
with:
github-token: ${{ secrets.GH_TOKEN }}
script: |
const path = require('path');
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/bot-pr-review-chain.js'));
const owner = context.repo.owner;
const repo = context.repo.repo;
const manual = (process.env.PR_NUMBER_INPUT || '').trim();
const prNumber = manual ? parseInt(manual, 10) : null;
if (manual && Number.isNaN(prNumber)) {
throw new Error(`Invalid pr_number: ${manual}`);
}
await chain.recoverStalledBotPrs(github, owner, repo, { prNumber }, core);
+1 -15
View File
@@ -12,7 +12,7 @@ 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:
@@ -83,16 +83,6 @@ jobs:
};
}
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) => {
@@ -143,10 +133,6 @@ jobs:
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,
+69
View File
@@ -0,0 +1,69 @@
# Knowledge Embedding Model Configuration
Knowledge / RAG search uses vector **embeddings**. The default embedding model is
`text-embedding-3-small`. Many OpenAI keys or projects do not have access to that
model (organization tier, model allowlist, or regional restrictions), which caused
knowledge search to silently degrade to local substring matching.
PraisonAIUI now makes this failure mode configurable and observable.
## Configure the embedding model
Set an accessible model via environment variable:
```bash
export AIUI_EMBEDDING_MODEL=text-embedding-ada-002
```
`OPENAI_EMBEDDING_MODEL` is also honoured as a fallback if `AIUI_EMBEDDING_MODEL`
is not set.
## Fallback chain
The OpenAI-compatible `/v1/embeddings` endpoint tries the requested (or default)
model first, then each model in the fallback chain if the primary returns a
`model_not_found` / 403 access error:
```bash
export AIUI_EMBEDDING_FALLBACK_MODELS="text-embedding-ada-002,text-embedding-3-small"
```
If every candidate is inaccessible, the endpoint returns `503` with an actionable
`model_not_found` error instead of a generic `500`.
## Observing degraded search
`GET /api/knowledge/status` reports embedding availability:
```json
{
"total": 12,
"backend": "SDKKnowledgeManager",
"status": "degraded",
"embedding": {"available": false, "error": "Project does not have access to model `text-embedding-3-small`"},
"warnings": ["Embedding model unavailable; knowledge search degraded to local text matching. Set AIUI_EMBEDDING_MODEL to an accessible model."]
}
```
`POST /api/knowledge/search` indicates whether results came from vector search or a
degraded local fallback:
```json
{
"results": [],
"count": 0,
"search_mode": "fallback",
"warning": "Vector search unavailable: Project does not have access to model text-embedding-3-small. Set AIUI_EMBEDDING_MODEL=text-embedding-ada-002 or enable embedding models in your provider project."
}
```
A dashboard can show a warning banner whenever `search_mode == "fallback"` or
`embedding.available == false`.
## Model reference
| Model | Notes |
|-------|-------|
| `text-embedding-3-small` | Default — best cost/quality when available |
| `text-embedding-3-large` | Higher quality — may also be restricted |
| `text-embedding-ada-002` | Legacy — widely available on older projects |
+16 -13
View File
@@ -44,15 +44,18 @@ import sys
# ── Import praisonaiui ──────────────────────────────────────
import praisonaiui as aiui
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from _shared.console import icon, safe_print
# ── Check gateway availability ──────────────────────────────
try:
from praisonaiui.integration import AIUIGateway
from praisonaiagents import Agent
GATEWAY_OK = True
except ImportError as e:
print(f"⚠️ Gateway dependencies missing: {e}")
print(" Install with: pip install praisonai praisonaiagents")
print(" Falling back to standalone mode.")
safe_print(f"{icon('⚠️', '[WARN]')} Gateway dependencies missing: {e}")
safe_print(" Install with: pip install praisonai praisonaiagents")
safe_print(" Falling back to standalone mode.")
GATEWAY_OK = False
# ── Set dashboard style ─────────────────────────────────────
@@ -149,15 +152,15 @@ def _register_agents_in_dashboard():
"model": agent_def["model"],
"icon": agent_def["icon"],
})
print(f" ✓ Dashboard: {agent_def['icon']} {agent_def['name']}")
safe_print(f" {icon('✓', '[OK]')} Dashboard: {agent_def['name']}")
# ── Main ────────────────────────────────────────────────────
async def main():
host = os.getenv("HOST", "127.0.0.1")
if GATEWAY_OK:
print("🚀 Starting with Gateway mode (full execution + streaming)")
print()
safe_print(f"{icon('🚀', '[START]')} Starting with Gateway mode (full execution + streaming)")
safe_print()
# Create the gateway
gateway = AIUIGateway(
@@ -174,22 +177,22 @@ async def main():
self_reflect=False,
)
gateway.register_agent(agent, agent_id=agent_def["agent_id"])
print(f" ✓ Gateway: {agent_def['icon']} {agent_def['name']} ({agent_def['model']})")
safe_print(f" {icon('✓', '[OK]')} Gateway: {agent_def['name']} ({agent_def['model']})")
# ── Register agents in dashboard CRUD storage ───────
_register_agents_in_dashboard()
print()
print(f" Dashboard: http://localhost:8083")
print(f" WebSocket: ws://localhost:8083/ws")
print(f" Inspector: http://localhost:8083 → 🔍 Feature Inspector")
print()
safe_print()
safe_print(" Dashboard: http://localhost:8083")
safe_print(" WebSocket: ws://localhost:8083/ws")
safe_print(f" Inspector: http://localhost:8083 {icon('→', '->')} {icon('🔍', '')} Feature Inspector")
safe_print()
# Start the gateway — this runs the full server
await gateway.start()
else:
# Fallback to standalone mode (same as example 15)
print("📦 Starting in standalone mode (CRUD only, no agent execution)")
safe_print(f"{icon('📦', '[INFO]')} Starting in standalone mode (CRUD only, no agent execution)")
from praisonaiui.server import create_app
import uvicorn
+21 -18
View File
@@ -26,15 +26,18 @@ import sys
import praisonaiui as aiui
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from _shared.console import icon, safe_print
# ── Check gateway availability ──────────────────────────────
try:
from praisonaiui.integration import AIUIGateway
from praisonaiagents import Agent
GATEWAY_OK = True
except ImportError as e:
print(f"⚠️ Gateway dependencies not found: {e}")
print(" Install with: pip install praisonai praisonaiagents")
print(" Falling back to standalone mode (REST APIs only).\n")
safe_print(f"{icon('⚠️', '[WARN]')} Gateway dependencies not found: {e}")
safe_print(" Install with: pip install praisonai praisonaiagents")
safe_print(" Falling back to standalone mode (REST APIs only).\n")
GATEWAY_OK = False
aiui.set_style("dashboard")
@@ -115,7 +118,7 @@ def _register_agents_in_dashboard():
"model": agent_def["model"],
"icon": agent_def["icon"],
})
print(f" ✓ Agent: {agent_def['icon']} {agent_def['name']}")
safe_print(f" {icon('✓', '[OK]')} Agent: {agent_def['name']}")
# ── Main ────────────────────────────────────────────────────
@@ -125,8 +128,8 @@ async def main():
host = os.getenv("HOST", "127.0.0.1")
if GATEWAY_OK:
print("🚀 Starting with Gateway mode (full execution + streaming)")
print()
safe_print(f"{icon('🚀', '[START]')} Starting with Gateway mode (full execution + streaming)")
safe_print()
gateway = AIUIGateway(host=host, port=port)
@@ -139,32 +142,32 @@ async def main():
reflection=False,
)
gateway.register_agent(agent, agent_id=agent_def["agent_id"])
print(f" ✓ Gateway: {agent_def['icon']} {agent_def['name']} ({agent_def['model']})")
safe_print(f" {icon('✓', '[OK]')} Gateway: {agent_def['name']} ({agent_def['model']})")
# Also register in dashboard CRUD
_register_agents_in_dashboard()
print()
print(f" Dashboard: http://localhost:{port}")
print(f" Explorer: http://localhost:{port} → 🔬 Feature Explorer")
print(f" WebSocket: ws://localhost:{port}/ws")
print()
safe_print()
safe_print(f" Dashboard: http://localhost:{port}")
safe_print(f" Explorer: http://localhost:{port} {icon('→', '->')} {icon('🔬', '')} Feature Explorer")
safe_print(f" WebSocket: ws://localhost:{port}/ws")
safe_print()
await gateway.start()
else:
# Standalone: REST APIs only, no agent execution
print("📦 Starting in standalone mode (REST APIs only, no agent execution)")
safe_print(f"{icon('📦', '[INFO]')} Starting in standalone mode (REST APIs only, no agent execution)")
from praisonaiui.server import create_app
import uvicorn
_register_agents_in_dashboard()
app = create_app()
print()
print(f" Dashboard: http://localhost:{port}")
print(f" Explorer: http://localhost:{port} → 🔬 Feature Explorer")
print(f" Note: POST endpoints may fail without gateway")
print()
safe_print()
safe_print(f" Dashboard: http://localhost:{port}")
safe_print(f" Explorer: http://localhost:{port} {icon('→', '->')} {icon('🔬', '')} Feature Explorer")
safe_print(" Note: POST endpoints may fail without gateway")
safe_print()
config = uvicorn.Config(app, host=host, port=port, log_level="info")
server = uvicorn.Server(config)
+22 -18
View File
@@ -25,9 +25,13 @@ Usage:
import asyncio
import os
import sys
import praisonaiui as aiui
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from _shared.console import icon, safe_print
# ── Check gateway availability ──────────────────────────────
try:
from praisonaiagents import Agent
@@ -35,9 +39,9 @@ try:
from praisonaiui.integration import AIUIGateway
GATEWAY_OK = True
except ImportError as e:
print(f"⚠️ Gateway dependencies not found: {e}")
print(" Install with: pip install praisonai praisonaiagents")
print(" Falling back to standalone mode (REST APIs only).\n")
safe_print(f"{icon('⚠️', '[WARN]')} Gateway dependencies not found: {e}")
safe_print(" Install with: pip install praisonai praisonaiagents")
safe_print(" Falling back to standalone mode (REST APIs only).\n")
GATEWAY_OK = False
aiui.set_style("dashboard")
@@ -127,7 +131,7 @@ def _register_agents_in_dashboard():
"model": agent_def["model"],
"icon": agent_def["icon"],
})
print(f" ✓ Agent: {agent_def['icon']} {agent_def['name']}")
safe_print(f" {icon('✓', '[OK]')} Agent: {agent_def['name']}")
# ── Main ────────────────────────────────────────────────────
@@ -137,8 +141,8 @@ async def main():
host = os.getenv("HOST", "127.0.0.1")
if GATEWAY_OK:
print("🚀 Starting with Gateway mode (full execution + streaming)")
print()
safe_print(f"{icon('🚀', '[START]')} Starting with Gateway mode (full execution + streaming)")
safe_print()
gateway = AIUIGateway(host=host, port=port)
@@ -151,21 +155,21 @@ async def main():
reflection=False,
)
gateway.register_agent(agent, agent_id=agent_def["agent_id"])
print(f" ✓ Gateway: {agent_def['icon']} {agent_def['name']} ({agent_def['model']})")
safe_print(f" {icon('✓', '[OK]')} Gateway: {agent_def['name']} ({agent_def['model']})")
# Also register in dashboard CRUD
_register_agents_in_dashboard()
print()
print(f" Dashboard: http://localhost:{port}")
print(f" Explorer: http://localhost:{port} → 🔬 Feature Explorer")
print(f" WebSocket: ws://localhost:{port}/ws")
print()
safe_print()
safe_print(f" Dashboard: http://localhost:{port}")
safe_print(f" Explorer: http://localhost:{port} {icon('→', '->')} {icon('🔬', '')} Feature Explorer")
safe_print(f" WebSocket: ws://localhost:{port}/ws")
safe_print()
await gateway.start()
else:
# Standalone: REST APIs only, no agent execution
print("📦 Starting in standalone mode (REST APIs only, no agent execution)")
safe_print(f"{icon('📦', '[INFO]')} Starting in standalone mode (REST APIs only, no agent execution)")
import uvicorn
from praisonaiui.server import create_app
@@ -173,11 +177,11 @@ async def main():
_register_agents_in_dashboard()
app = create_app()
print()
print(f" Dashboard: http://localhost:{port}")
print(f" Explorer: http://localhost:{port} → 🔬 Feature Explorer")
print(" Note: POST endpoints may fail without gateway")
print()
safe_print()
safe_print(f" Dashboard: http://localhost:{port}")
safe_print(f" Explorer: http://localhost:{port} {icon('→', '->')} {icon('🔬', '')} Feature Explorer")
safe_print(" Note: POST endpoints may fail without gateway")
safe_print()
config = uvicorn.Config(app, host=host, port=port, log_level="info")
server = uvicorn.Server(config)
+21 -18
View File
@@ -32,6 +32,9 @@ import asyncio
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from _shared.console import icon, safe_print
# ── Check prerequisites ─────────────────────────────────────────────
try:
@@ -40,8 +43,8 @@ try:
SDK_OK = True
except ImportError:
SDK_OK = False
print("⚠️ Missing dependencies. Install with:")
print(" pip install praisonai[email] praisonaiagents")
safe_print(f"{icon('⚠️', '[WARN]')} Missing dependencies. Install with:")
safe_print(" pip install praisonai[email] praisonaiagents")
sys.exit(1)
@@ -68,8 +71,8 @@ agent = Agent(
async def run_simple():
"""Start email bot with automatic env var resolution."""
bot = Bot("email", agent=agent)
print(f"📧 Starting email bot for: {os.getenv('EMAIL_ADDRESS', '(not set)')}")
print(" Listening for new emails... (Ctrl+C to stop)")
safe_print(f"{icon('📧', '[EMAIL]')} Starting email bot for: {os.getenv('EMAIL_ADDRESS', '(not set)')}")
safe_print(" Listening for new emails... (Ctrl+C to stop)")
await bot.start()
@@ -96,9 +99,9 @@ async def run_with_handlers():
@bot.on_message
async def log_email(message):
subject = message.metadata.get("subject", "(no subject)")
print(f"📨 From: {message.sender.username}")
print(f" Subject: {subject}")
print(f" Preview: {message.text[:100]}...")
safe_print(f"{icon('📨', '[MSG]')} From: {message.sender.username}")
safe_print(f" Subject: {subject}")
safe_print(f" Preview: {message.text[:100]}...")
# Custom command: emails with subject starting with "/status"
@bot.on_command("status")
@@ -118,11 +121,11 @@ async def run_with_handlers():
reply_to=message.message_id,
)
print(f"📧 Starting email bot with custom handlers")
print(f" Email: {bot._email_address}")
print(f" Poll interval: {config.polling_interval}s")
print(f" Allowed senders: {'all' if not config.allowed_users else config.allowed_users}")
print(" Listening... (Ctrl+C to stop)")
safe_print(f"{icon('📧', '[EMAIL]')} Starting email bot with custom handlers")
safe_print(f" Email: {bot._email_address}")
safe_print(f" Poll interval: {config.polling_interval}s")
safe_print(f" Allowed senders: {'all' if not config.allowed_users else config.allowed_users}")
safe_print(" Listening... (Ctrl+C to stop)")
await bot.start()
@@ -131,17 +134,17 @@ async def run_with_handlers():
async def run_probe():
"""Test email server connectivity."""
bot = Bot("email", agent=agent)
print("🔍 Testing email server connectivity...")
safe_print(f"{icon('🔍', '[PROBE]')} Testing email server connectivity...")
result = await bot.probe()
print(f"\n Connection: {'✅ OK' if result.ok else '❌ FAILED'}")
print(f" Platform: {result.platform}")
safe_print(f"\n Connection: {icon('✅', '[OK]') + ' OK' if result.ok else icon('❌', '[FAIL]') + ' FAILED'}")
safe_print(f" Platform: {result.platform}")
if result.bot_username:
print(f" Email: {result.bot_username}")
safe_print(f" Email: {result.bot_username}")
if result.elapsed_ms:
print(f" Latency: {result.elapsed_ms:.0f}ms")
safe_print(f" Latency: {result.elapsed_ms:.0f}ms")
if result.error:
print(f" Error: {result.error}")
safe_print(f" Error: {result.error}")
# ── Main ────────────────────────────────────────────────────────────
+26 -24
View File
@@ -42,7 +42,9 @@ import sys
# ── Imports ─────────────────────────────────────────────────────────
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "src"))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from _shared.console import icon, safe_print
from praisonaiui.server import create_app
import uvicorn
@@ -91,11 +93,11 @@ def seed_email_channel():
})
if r.status_code in (200, 201):
channel_id = r.json().get("id", "unknown")
print(f" ✅ Email channel registered: {channel_id}")
safe_print(f" {icon('✅', '[OK]')} Email channel registered: {channel_id}")
else:
print(f" ⚠️ Channel registration: {r.status_code} — {r.json()}")
safe_print(f" {icon('⚠️', '[WARN]')} Channel registration: {r.status_code} - {r.json()}")
else:
print(" ✓ Email channel already exists")
safe_print(f" {icon('✓', '[OK]')} Email channel already exists")
# Also seed other channels for comparison in dashboard
for name, platform in [("Discord #general", "discord"), ("Telegram Support", "telegram")]:
@@ -106,7 +108,7 @@ def seed_email_channel():
"platform": platform,
"config": {},
})
print(f" ✓ Seeded {platform} channel")
safe_print(f" {icon('✓', '[OK]')} Seeded {platform} channel")
# ── Verify email platform support ──────────────────────────────────
@@ -122,8 +124,8 @@ def verify_email_support():
if r.status_code == 200:
platforms = r.json().get("platforms", [])
email_supported = "email" in platforms
print(f" Supported platforms: {platforms}")
print(f" Email supported: {'✅ Yes' if email_supported else '❌ No — add email to SUPPORTED_PLATFORMS'}")
safe_print(f" Supported platforms: {platforms}")
safe_print(f" Email supported: {icon('✅', '[OK]') + ' Yes' if email_supported else icon('❌', '[X]') + ' No - add email to SUPPORTED_PLATFORMS'}")
return email_supported
return False
@@ -137,51 +139,51 @@ def test_channel_api():
app = create_app()
client = TestClient(app)
print("\n📋 Channel API Test")
print("=" * 40)
safe_print(f"\n{icon('📋', '[TEST]')} Channel API Test")
safe_print("=" * 40)
# List channels
r = client.get("/api/channels")
channels = r.json().get("channels", [])
print(f" GET /api/channels: {len(channels)} channels")
safe_print(f" GET /api/channels: {len(channels)} channels")
for ch in channels:
icon = "📧" if ch.get("platform") == "email" else "💬"
print(f" {icon} {ch.get('name', '?')} ({ch.get('platform', '?')})")
ch_icon = icon("📧", "[email]") if ch.get("platform") == "email" else icon("💬", "[chat]")
safe_print(f" {ch_icon} {ch.get('name', '?')} ({ch.get('platform', '?')})")
# Get email channel status
email_ch = next((ch for ch in channels if ch.get("platform") == "email"), None)
if email_ch:
ch_id = email_ch.get("id")
r = client.get(f"/api/channels/{ch_id}/status")
print(f" GET /api/channels/{ch_id}/status: {r.json()}")
safe_print(f" GET /api/channels/{ch_id}/status: {r.json()}")
print()
safe_print()
# ── Main ────────────────────────────────────────────────────────────
if __name__ == "__main__":
print("📧 PraisonAIUI — Email Channel Integration")
print("=" * 50)
print()
safe_print(f"{icon('📧', '[EMAIL]')} PraisonAIUI - Email Channel Integration")
safe_print("=" * 50)
safe_print()
if "--test" in sys.argv:
# Test mode: verify APIs + seed data
print("🔍 Verifying email platform support...")
safe_print(f"{icon('🔍', '[VERIFY]')} Verifying email platform support...")
verify_email_support()
print()
print("🌱 Seeding email channel...")
safe_print()
safe_print(f"{icon('🌱', '[SEED]')} Seeding email channel...")
seed_email_channel()
test_channel_api()
else:
# Run the dashboard
print("🌱 Seeding channels...")
safe_print(f"{icon('🌱', '[SEED]')} Seeding channels...")
seed_email_channel()
print()
safe_print()
app = create_app()
print(f"✅ Dashboard at http://localhost:8084")
print(f" Channels: http://localhost:8084/api/channels")
print(f" Platforms: http://localhost:8084/api/channels/platforms")
safe_print(f"{icon('✅', '[OK]')} Dashboard at http://localhost:8084")
safe_print(" Channels: http://localhost:8084/api/channels")
safe_print(" Platforms: http://localhost:8084/api/channels/platforms")
host = os.getenv("HOST", "127.0.0.1")
uvicorn.run(app, host=host, port=8084, log_level="info")
+32 -29
View File
@@ -33,6 +33,9 @@ import asyncio
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from _shared.console import icon, safe_print
# ── Check prerequisites ─────────────────────────────────────────────
try:
@@ -41,8 +44,8 @@ try:
SDK_OK = True
except ImportError:
SDK_OK = False
print("⚠️ Missing dependencies. Install with:")
print(" pip install praisonai agentmail praisonaiagents")
safe_print(f"{icon('⚠️', '[WARN]')} Missing dependencies. Install with:")
safe_print(" pip install praisonai agentmail praisonaiagents")
sys.exit(1)
@@ -69,9 +72,9 @@ agent = Agent(
async def run_simple():
"""Start agentmail bot with automatic env var resolution."""
bot = Bot("agentmail", agent=agent)
print(f"📩 Starting AgentMail bot")
print(f" API key: {'✅ set' if os.getenv('AGENTMAIL_API_KEY') else '❌ not set'}")
print(" Listening for new emails... (Ctrl+C to stop)")
safe_print(f"{icon('📩', '[MAIL]')} Starting AgentMail bot")
safe_print(f" API key: {icon('✅', '[OK]') + ' set' if os.getenv('AGENTMAIL_API_KEY') else icon('❌', '[X]') + ' not set'}")
safe_print(" Listening for new emails... (Ctrl+C to stop)")
await bot.start()
@@ -99,9 +102,9 @@ async def run_with_handlers():
@bot.on_message
async def log_email(message):
subject = message.metadata.get("subject", "(no subject)")
print(f"📨 From: {message.sender.username}")
print(f" Subject: {subject}")
print(f" Preview: {message.text[:100]}...")
safe_print(f"{icon('📨', '[MSG]')} From: {message.sender.username}")
safe_print(f" Subject: {subject}")
safe_print(f" Preview: {message.text[:100]}...")
# Custom command: emails with subject starting with "/status"
@bot.on_command("status")
@@ -121,10 +124,10 @@ async def run_with_handlers():
reply_to=message.message_id,
)
print(f"📩 Starting AgentMail bot with custom handlers")
print(f" Email: {bot.email_address or '(will be assigned on start)'}")
print(f" Poll interval: {config.polling_interval}s")
print(" Listening... (Ctrl+C to stop)")
safe_print(f"{icon('📩', '[MAIL]')} Starting AgentMail bot with custom handlers")
safe_print(f" Email: {bot.email_address or '(will be assigned on start)'}")
safe_print(f" Poll interval: {config.polling_interval}s")
safe_print(" Listening... (Ctrl+C to stop)")
await bot.start()
@@ -136,30 +139,30 @@ async def run_inbox_demo():
token=os.environ.get("AGENTMAIL_API_KEY", ""),
)
print("📩 AgentMail Inbox Lifecycle Demo")
print("=" * 50)
safe_print(f"{icon('📩', '[MAIL]')} AgentMail Inbox Lifecycle Demo")
safe_print("=" * 50)
# Create inbox
print("\n1. Creating inbox...")
safe_print("\n1. Creating inbox...")
inbox = await bot.create_inbox(domain=os.getenv("AGENTMAIL_DOMAIN"))
print(f" ✅ Created: {inbox.get('email_address', 'unknown')}")
print(f" ID: {inbox.get('id', 'unknown')}")
safe_print(f" {icon('✅', '[OK]')} Created: {inbox.get('email_address', 'unknown')}")
safe_print(f" ID: {inbox.get('id', 'unknown')}")
# List inboxes
print("\n2. Listing inboxes...")
safe_print("\n2. Listing inboxes...")
inboxes = await bot.list_inboxes()
print(f" Found: {len(inboxes)} inbox(es)")
safe_print(f" Found: {len(inboxes)} inbox(es)")
for ib in inboxes:
print(f" 📧 {ib.get('email_address', '?')} (id: {ib.get('id', '?')})")
safe_print(f" {icon('📧', '[EMAIL]')} {ib.get('email_address', '?')} (id: {ib.get('id', '?')})")
# Delete inbox
inbox_id = inbox.get("id", "")
if inbox_id:
print(f"\n3. Deleting inbox {inbox_id}...")
safe_print(f"\n3. Deleting inbox {inbox_id}...")
deleted = await bot.delete_inbox(inbox_id)
print(f" {'✅ Deleted' if deleted else '❌ Failed to delete'}")
safe_print(f" {icon('✅', '[OK]') + ' Deleted' if deleted else icon('❌', '[X]') + ' Failed to delete'}")
print("\n✅ Inbox lifecycle complete")
safe_print(f"\n{icon('✅', '[OK]')} Inbox lifecycle complete")
# ── Probe: Test connectivity ────────────────────────────────────────
@@ -167,17 +170,17 @@ async def run_inbox_demo():
async def run_probe():
"""Test AgentMail API connectivity."""
bot = Bot("agentmail", agent=agent)
print("🔍 Testing AgentMail API connectivity...")
safe_print(f"{icon('🔍', '[PROBE]')} Testing AgentMail API connectivity...")
result = await bot.probe()
print(f"\n Connection: {'✅ OK' if result.ok else '❌ FAILED'}")
print(f" Platform: {result.platform}")
safe_print(f"\n Connection: {icon('✅', '[OK]') + ' OK' if result.ok else icon('❌', '[FAIL]') + ' FAILED'}")
safe_print(f" Platform: {result.platform}")
if result.bot_username:
print(f" Email: {result.bot_username}")
safe_print(f" Email: {result.bot_username}")
if result.elapsed_ms:
print(f" Latency: {result.elapsed_ms:.0f}ms")
safe_print(f" Latency: {result.elapsed_ms:.0f}ms")
if result.error:
print(f" Error: {result.error}")
safe_print(f" Error: {result.error}")
# ── Main ────────────────────────────────────────────────────────────
+27 -25
View File
@@ -40,7 +40,9 @@ import sys
# ── Imports ─────────────────────────────────────────────────────────
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "src"))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from _shared.console import icon, safe_print
from praisonaiui.server import create_app
import uvicorn
@@ -88,11 +90,11 @@ def seed_agentmail_channel():
})
if r.status_code in (200, 201):
channel_id = r.json().get("id", "unknown")
print(f" ✅ AgentMail channel registered: {channel_id}")
safe_print(f" {icon('✅', '[OK]')} AgentMail channel registered: {channel_id}")
else:
print(f" ⚠️ Channel registration: {r.status_code} — {r.json()}")
safe_print(f" {icon('⚠️', '[WARN]')} Channel registration: {r.status_code} - {r.json()}")
else:
print(" ✓ AgentMail channel already exists")
safe_print(f" {icon('✓', '[OK]')} AgentMail channel already exists")
# Also seed email + telegram for comparison in dashboard
for name, platform in [("Support Email", "email"), ("Telegram Support", "telegram")]:
@@ -103,7 +105,7 @@ def seed_agentmail_channel():
"platform": platform,
"config": {},
})
print(f" ✓ Seeded {platform} channel")
safe_print(f" {icon('✓', '[OK]')} Seeded {platform} channel")
# ── Verify agentmail platform support ──────────────────────────────
@@ -120,9 +122,9 @@ def verify_agentmail_support():
platforms = r.json().get("platforms", [])
am_supported = "agentmail" in platforms
email_supported = "email" in platforms
print(f" Supported platforms: {platforms}")
print(f" email supported: {'✅ Yes' if email_supported else '❌ No'}")
print(f" agentmail supported: {'✅ Yes' if am_supported else '❌ No'}")
safe_print(f" Supported platforms: {platforms}")
safe_print(f" email supported: {icon('✅', '[OK]') + ' Yes' if email_supported else icon('❌', '[X]') + ' No'}")
safe_print(f" agentmail supported: {icon('✅', '[OK]') + ' Yes' if am_supported else icon('❌', '[X]') + ' No'}")
return am_supported
return False
@@ -136,51 +138,51 @@ def test_channel_api():
app = create_app()
client = TestClient(app)
print("\n📋 Channel API Test")
print("=" * 40)
safe_print(f"\n{icon('📋', '[TEST]')} Channel API Test")
safe_print("=" * 40)
# List channels
r = client.get("/api/channels")
channels = r.json().get("channels", [])
print(f" GET /api/channels: {len(channels)} channels")
safe_print(f" GET /api/channels: {len(channels)} channels")
for ch in channels:
icon = {"email": "📧", "agentmail": "📩"}.get(ch.get("platform", ""), "💬")
print(f" {icon} {ch.get('name', '?')} ({ch.get('platform', '?')})")
ch_icon = {"email": icon("📧", "[email]"), "agentmail": icon("📩", "[mail]")}.get(ch.get("platform", ""), icon("💬", "[chat]"))
safe_print(f" {ch_icon} {ch.get('name', '?')} ({ch.get('platform', '?')})")
# Get agentmail channel status
am_ch = next((ch for ch in channels if ch.get("platform") == "agentmail"), None)
if am_ch:
ch_id = am_ch.get("id")
r = client.get(f"/api/channels/{ch_id}/status")
print(f" GET /api/channels/{ch_id}/status: {r.json()}")
safe_print(f" GET /api/channels/{ch_id}/status: {r.json()}")
print()
safe_print()
# ── Main ────────────────────────────────────────────────────────────
if __name__ == "__main__":
print("📩 PraisonAIUI — AgentMail Channel Integration")
print("=" * 50)
print()
safe_print(f"{icon('📩', '[MAIL]')} PraisonAIUI - AgentMail Channel Integration")
safe_print("=" * 50)
safe_print()
if "--test" in sys.argv:
# Test mode: verify APIs + seed data
print("🔍 Verifying agentmail platform support...")
safe_print(f"{icon('🔍', '[VERIFY]')} Verifying agentmail platform support...")
verify_agentmail_support()
print()
print("🌱 Seeding agentmail channel...")
safe_print()
safe_print(f"{icon('🌱', '[SEED]')} Seeding agentmail channel...")
seed_agentmail_channel()
test_channel_api()
else:
# Run the dashboard
print("🌱 Seeding channels...")
safe_print(f"{icon('🌱', '[SEED]')} Seeding channels...")
seed_agentmail_channel()
print()
safe_print()
app = create_app()
print(f"✅ Dashboard at http://localhost:8084")
print(f" Channels: http://localhost:8084/api/channels")
print(f" Platforms: http://localhost:8084/api/channels/platforms")
safe_print(f"{icon('✅', '[OK]')} Dashboard at http://localhost:8084")
safe_print(" Channels: http://localhost:8084/api/channels")
safe_print(" Platforms: http://localhost:8084/api/channels/platforms")
host = os.getenv("HOST", "127.0.0.1")
uvicorn.run(app, host=host, port=8084, log_level="info")
+40
View File
@@ -0,0 +1,40 @@
"""Cross-platform console output for examples.
Legacy Windows consoles default stdout to a non-UTF code page (cp1252/cp437)
that cannot encode emoji such as U+1F680. Calling ``print`` with those
characters raises ``UnicodeEncodeError`` and crashes the example before the
server starts. These helpers mirror the ``_supports_unicode``/``_icon`` pattern
in ``praisonaiui.cli`` so examples degrade to ASCII instead of crashing while
preserving emoji on UTF-aware terminals.
Usage:
from _shared.console import safe_print, icon
safe_print(f"{icon('🚀', '[START]')} Starting gateway...")
"""
from __future__ import annotations
import sys
from typing import Any
def supports_unicode() -> bool:
try:
encoding = sys.stdout.encoding or ""
except Exception:
return False
return "utf" in encoding.lower()
def icon(symbol: str, fallback: str) -> str:
return symbol if supports_unicode() else fallback
def safe_print(*args: Any, **kwargs: Any) -> None:
sep = kwargs.pop("sep", " ")
text = sep.join(str(a) for a in args)
try:
print(text, **kwargs)
except UnicodeEncodeError:
print(text.encode("ascii", errors="replace").decode("ascii"), **kwargs)
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "aiui"
version = "0.3.134"
version = "0.3.138"
description = "YAML-driven website generator - CLI and compiler"
readme = "README.md"
license = {text = "MIT"}
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version information."""
__version__ = "0.3.134"
__version__ = "0.3.138"
+1 -1
View File
@@ -591,7 +591,7 @@ class SQLAlchemyDataStore(BaseDataStore):
base = Path(os.environ.get("AIUI_DATA_DIR", str(Path.home() / ".praisonaiui")))
base.mkdir(parents=True, exist_ok=True)
db_path = base / "aiui.db"
return f"sqlite+aiosqlite:///{db_path}"
return f"sqlite+aiosqlite:///{db_path.as_posix()}"
async def _ensure_initialized(self) -> None:
"""Lazy initialization of SQLAlchemy engine and tables."""
+74 -2
View File
@@ -27,6 +27,17 @@ from ._base import BaseFeatureProtocol
logger = logging.getLogger(__name__)
def _is_embedding_unavailable(err: BaseException) -> bool:
"""Detect an embedding model access / not-found failure."""
msg = str(err).lower()
return (
"does not have access" in msg
or "model_not_found" in msg
or "does not exist" in msg
or "notfounderror" in msg
)
# ── Knowledge Protocol ───────────────────────────────────────────────
@@ -95,6 +106,10 @@ class KnowledgeProtocol(ABC):
"""Health check for this backend."""
return {"status": "ok", "provider": self.__class__.__name__}
def last_search_meta(self) -> Dict[str, Any]:
"""Metadata about the most recent search (mode, error)."""
return {"mode": "vector", "error": None}
# ── Simple Knowledge Manager (Default, no deps) ─────────────────────
@@ -171,8 +186,12 @@ class SimpleKnowledgeManager(KnowledgeProtocol):
"status": "ok",
"provider": "SimpleKnowledgeManager",
"total_entries": len(self._entries),
"embedding": {"available": False, "reason": "no_vector_backend"},
}
def last_search_meta(self) -> Dict[str, Any]:
return {"mode": "local", "error": None}
# ── SDK Knowledge Manager (wraps praisonaiagents.knowledge) ──────────
@@ -191,6 +210,9 @@ class SDKKnowledgeManager(KnowledgeProtocol):
self._sdk_knowledge = None
self._sdk_probed = False # True once we've verified SDK actually works
self._local_index: Dict[str, Dict[str, Any]] = {}
self._embedding_unavailable = False
self._embedding_error: Optional[str] = None
self._last_search_meta: Dict[str, Any] = {"mode": "vector", "error": None}
def _get_sdk_knowledge(self) -> Any:
"""Lazy-init SDK Knowledge with health probe.
@@ -242,6 +264,10 @@ class SDKKnowledgeManager(KnowledgeProtocol):
"using local in-memory index"
)
self._sdk_knowledge = None
elif _is_embedding_unavailable(e):
self._embedding_unavailable = True
self._embedding_error = err_msg
logger.warning("Embedding model unavailable: %s", e)
else:
# Other errors (e.g. empty DB) are fine — SDK works
pass
@@ -294,6 +320,7 @@ class SDKKnowledgeManager(KnowledgeProtocol):
try:
results = sdk.search(query, limit=limit, user_id="praisonaiui")
if isinstance(results, list) and results:
self._last_search_meta = {"mode": "vector", "error": None}
return [
{
"id": str(r.get("id", i)),
@@ -305,6 +332,9 @@ class SDKKnowledgeManager(KnowledgeProtocol):
]
except BaseException as e:
logger.warning("SDK knowledge search failed: %s; falling back to local", e)
if _is_embedding_unavailable(e):
self._embedding_unavailable = True
self._embedding_error = str(e)
# Fallback to local text search
query_lower = query.lower()
@@ -314,8 +344,26 @@ class SDKKnowledgeManager(KnowledgeProtocol):
results.append(e)
if len(results) >= limit:
break
if self._embedding_unavailable:
self._last_search_meta = {
"mode": "fallback",
"error": (
"Vector search unavailable: "
f"{self._embedding_error or 'embedding model not accessible'}. "
"Set AIUI_EMBEDDING_MODEL=text-embedding-ada-002 or enable embedding "
"models in your provider project."
),
}
elif sdk is not None:
self._last_search_meta = {"mode": "vector", "error": None}
else:
self._last_search_meta = {"mode": "local", "error": None}
return results
def last_search_meta(self) -> Dict[str, Any]:
return dict(self._last_search_meta)
def add_file(
self,
file_path: str,
@@ -453,11 +501,25 @@ class SDKKnowledgeManager(KnowledgeProtocol):
def health(self) -> Dict[str, Any]:
sdk = self._get_sdk_knowledge()
embedding_available = sdk is not None and not self._embedding_unavailable
warnings: List[str] = []
embedding: Dict[str, Any] = {"available": embedding_available}
if self._embedding_unavailable:
embedding["error"] = self._embedding_error
warnings.append(
"Embedding model unavailable; knowledge search degraded to local text "
"matching. Set AIUI_EMBEDDING_MODEL to an accessible model."
)
status = "ok"
if sdk is None or self._embedding_unavailable:
status = "degraded"
return {
"status": "ok" if sdk is not None else "degraded",
"status": status,
"provider": "SDKKnowledgeManager",
"sdk_available": sdk is not None,
"total_indexed": len(self._local_index),
"embedding": embedding,
"warnings": warnings,
}
@@ -626,7 +688,15 @@ class KnowledgeFeature(BaseFeatureProtocol):
query=query,
limit=body.get("limit", 10),
)
return JSONResponse({"results": results, "count": len(results)})
meta = mgr.last_search_meta()
return JSONResponse(
{
"results": results,
"count": len(results),
"search_mode": meta.get("mode", "unknown"),
"warning": meta.get("error"),
}
)
async def _add_file(self, request: Request) -> JSONResponse:
"""POST /api/knowledge/add-file — ingest a file."""
@@ -675,6 +745,8 @@ class KnowledgeFeature(BaseFeatureProtocol):
"files": stats.get("files", 0),
"backend": h.get("provider", "unknown"),
"status": h.get("status", "ok"),
"embedding": h.get("embedding", {"available": True}),
"warnings": h.get("warnings", []),
}
)
+4 -4
View File
@@ -175,7 +175,7 @@ async def run_startup_hooks() -> None:
return
_log.info(f"Running {len(_startup_hooks)} startup hooks")
start_time = time.time()
start_time = time.perf_counter()
for hook in _startup_hooks:
try:
@@ -187,7 +187,7 @@ async def run_startup_hooks() -> None:
_log.error(f"Startup hook failed: {getattr(hook, '__name__', str(hook))}: {e}")
# Continue with other hooks - individual failures shouldn't break startup
execution_time = time.time() - start_time
execution_time = time.perf_counter() - start_time
_lifecycle_state.update(
{
"startup_completed": True,
@@ -209,7 +209,7 @@ async def run_shutdown_hooks() -> None:
_lifecycle_state["shutdown_initiated"] = True
_log.info(f"Running {len(_shutdown_hooks)} shutdown hooks")
start_time = time.time()
start_time = time.perf_counter()
# Get shutdown timeout from environment
timeout = float(os.environ.get("AIUI_SHUTDOWN_TIMEOUT", "30.0"))
@@ -227,7 +227,7 @@ async def run_shutdown_hooks() -> None:
try:
await asyncio.wait_for(_execute_hooks(), timeout=timeout)
execution_time = time.time() - start_time
execution_time = time.perf_counter() - start_time
_lifecycle_state["shutdown_time"] = execution_time
_log.info(f"Shutdown hooks completed in {execution_time:.3f}s")
except asyncio.TimeoutError:
+76 -18
View File
@@ -6,6 +6,7 @@ Wraps praisonai.capabilities functions as HTTP endpoints.
from __future__ import annotations
import os
import time
import uuid
from dataclasses import asdict
@@ -17,6 +18,35 @@ from starlette.routing import Route
from ._base import BaseFeatureProtocol
def _default_embedding_model() -> str:
"""Resolve the default embedding model from environment."""
return os.environ.get(
"AIUI_EMBEDDING_MODEL",
os.environ.get("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small"),
)
def _embedding_fallback_models() -> list[str]:
"""Resolve the embedding fallback chain from environment."""
raw = os.environ.get(
"AIUI_EMBEDDING_FALLBACK_MODELS",
"text-embedding-ada-002,text-embedding-3-small",
)
return [m.strip() for m in raw.split(",") if m.strip()]
def _is_model_not_found(err: BaseException) -> bool:
"""Heuristically detect embedding model access / not-found errors."""
msg = str(err).lower()
return (
"does not have access" in msg
or "model_not_found" in msg
or "does not exist" in msg
or "notfounderror" in msg
)
# Lazy import capabilities
_capabilities = None
@@ -280,29 +310,57 @@ class OpenAIAPIFeature(BaseFeatureProtocol):
body = await request.json()
input_text = body.get("input", "")
model = body.get("model", "text-embedding-3-small")
model = body.get("model") or _default_embedding_model()
try:
result = await caps.aembed(
input=input_text,
model=model,
)
candidates = [model]
for fallback in _embedding_fallback_models():
if fallback not in candidates:
candidates.append(fallback)
data = _result_to_dict(result)
last_err: BaseException | None = None
for candidate in candidates:
try:
result = await caps.aembed(
input=input_text,
model=candidate,
)
data = _result_to_dict(result)
return JSONResponse(
{
"object": "list",
"data": [
{
"object": "embedding",
"index": 0,
"embedding": data.get("embedding", []),
}
],
"model": candidate,
"usage": data.get("usage", {"prompt_tokens": 0, "total_tokens": 0}),
}
)
except Exception as e:
last_err = e
if not _is_model_not_found(e):
break
if last_err is not None and _is_model_not_found(last_err):
return JSONResponse(
{
"object": "list",
"data": [
{"object": "embedding", "index": 0, "embedding": data.get("embedding", [])}
],
"model": model,
"usage": data.get("usage", {"prompt_tokens": 0, "total_tokens": 0}),
}
)
except Exception as e:
return JSONResponse(
{"error": {"message": str(e), "type": "api_error"}}, status_code=500
"error": {
"message": (
f"Embedding model unavailable: {last_err}. "
"Set AIUI_EMBEDDING_MODEL to a model your project can access "
"(e.g. text-embedding-ada-002)."
),
"type": "model_not_found",
}
},
status_code=503,
)
return JSONResponse(
{"error": {"message": str(last_err), "type": "api_error"}}, status_code=500
)
async def _image_generate(self, request: Request) -> JSONResponse:
"""POST /v1/images/generations — Generate images."""
+24 -4
View File
@@ -2860,7 +2860,14 @@ def create_app(
require_auth: bool = False,
config_path: Optional[Path] = None,
) -> Starlette:
"""Create the Starlette application."""
"""Create the Starlette application.
URL-token auth (``TokenQueryMiddleware``) is opt-in: it activates only
when ``AIUI_REQUIRE_TOKEN`` is truthy, YAML ``auth.requireToken`` is set,
or ``require_auth=True``. A bare ``GATEWAY_AUTH_TOKEN`` /
``AIUI_URL_TOKEN`` env var (e.g. leaked into a developer shell after a
gateway run) no longer forces 401 on standalone ``/api/*`` (#238, #251).
"""
from praisonaiui.auth import (
AuthMiddleware,
login_handler,
@@ -2874,10 +2881,13 @@ def create_app(
config = load_config_from_yaml(config_path)
# Extract auth settings from config
_config_requires_token = False
if config:
auth_config = config.get("auth", {})
if auth_config.get("requireAuth") or auth_config.get("require_auth"):
require_auth = True
if auth_config.get("requireToken") or auth_config.get("require_token"):
_config_requires_token = True
middleware = [
Middleware(
@@ -2891,10 +2901,20 @@ def create_app(
# Add optional AUTH_ENFORCE middleware (env var driven)
middleware.append(Middleware(AuthEnforcementMiddleware))
# URL-token auth — opt-in via env. Enables query-param / cookie auth so
# dashboards can be shared by URL (Jupyter model).
# URL-token auth — opt-in. Enables query-param / cookie auth so dashboards
# can be shared by URL (Jupyter model). The mere presence of a token env
# var (e.g. GATEWAY_AUTH_TOKEN leaking into a developer shell after a
# gateway run) must NOT force auth on standalone create_app() — that broke
# ad-hoc TestClient scripts (#238, #251). Activation now requires an
# explicit opt-in: AIUI_REQUIRE_TOKEN=1, YAML auth.requireToken, or the
# require_auth flag.
_tok = os.environ.get("AIUI_URL_TOKEN") or os.environ.get("GATEWAY_AUTH_TOKEN")
if _tok:
_token_auth_opt_in = (
os.environ.get("AIUI_REQUIRE_TOKEN", "").lower() in ("true", "1", "yes")
or _config_requires_token
or require_auth
)
if _tok and _token_auth_opt_in:
from praisonaiui.auth import TokenQueryMiddleware
middleware.append(
Middleware(TokenQueryMiddleware, expected_token=_tok)
@@ -1364,6 +1364,46 @@ async function consumeToken(token) {
}
}
async function probeGatewayReachable(urlString) {
let base;
try {
base = new URL(urlString);
} catch (e) {
return { ok: false, reason: 'invalid_url' };
}
if (base.protocol !== 'http:' && base.protocol !== 'https:') {
return { ok: false, reason: 'invalid_url' };
}
const probe = new URL('/api/auth/status', base);
const sameOrigin = base.origin === window.location.origin;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(probe.toString(), {
method: 'GET',
credentials: 'omit',
signal: controller.signal,
});
clearTimeout(timeout);
if (res.type === 'opaque' || res.status === 0) {
return { ok: false, reason: 'opaque' };
}
return { ok: true, status: res.status };
} catch (e) {
clearTimeout(timeout);
if (e.name === 'AbortError') {
return { ok: false, reason: 'timeout' };
}
// A rejected fetch on a cross-origin URL is ambiguous: the gateway may be
// up but blocking CORS, or genuinely unreachable. The browser cannot tell
// these apart, so surface the "Continue anyway" opt-in rather than a
// definitive unreachable error. Same-origin rejections are genuine.
return { ok: false, reason: sameOrigin ? 'network' : 'opaque' };
}
}
async function maybeShowOnboarding() {
let params;
try {
@@ -1449,18 +1489,36 @@ function renderOnboarding(status) {
<button id="ob-continue" style="width:100%;padding:11px;border:none;border-radius:8px;background:var(--db-accent,#4f8cff);color:#fff;font-size:14px;font-weight:600;cursor:pointer">Continue</button>
<button id="ob-standalone" style="width:100%;padding:8px;margin-top:10px;border:none;background:none;color:var(--db-text-muted,#9aa4b2);font-size:13px;cursor:pointer;text-decoration:underline">Use standalone mode</button>
`);
overlay.querySelector('#ob-continue').addEventListener('click', async () => {
const continueBtn = overlay.querySelector('#ob-continue');
continueBtn.addEventListener('click', async () => {
const url = overlay.querySelector('#ob-url').value.trim();
const errEl = overlay.querySelector('#ob-url-err');
const override = overlay.querySelector('#ob-url-override');
errEl.textContent = '';
try {
await fetch(url, { method: 'HEAD', mode: 'no-cors' });
} catch (e) {
errEl.textContent = `Cannot reach gateway at ${url}`;
if (override && override.checked) {
state.step = 2;
render();
return;
}
state.step = 2;
render();
continueBtn.disabled = true;
continueBtn.textContent = 'Checking…';
const result = await probeGatewayReachable(url);
continueBtn.disabled = false;
continueBtn.textContent = 'Continue';
if (result.ok) {
state.step = 2;
render();
return;
}
if (result.reason === 'invalid_url') {
errEl.textContent = `Enter a valid http(s) URL`;
} else if (result.reason === 'opaque') {
errEl.innerHTML = `Cannot verify gateway from browser. <label style="cursor:pointer"><input id="ob-url-override" type="checkbox" style="vertical-align:middle"> Continue anyway</label>`;
} else if (result.reason === 'timeout') {
errEl.textContent = `Gateway did not respond at ${url}`;
} else {
errEl.textContent = `Cannot reach gateway at ${url}`;
}
});
overlay.querySelector('#ob-standalone').addEventListener('click', () => {
try { window.localStorage.setItem(ONBOARD_SKIP_KEY, '1'); } catch (e) { /* ignore */ }
@@ -36,6 +36,50 @@ let contextRefreshDebounce = null;
let lastContextPct = 0;
let finopsConfig = null;
let sessionCostState = { tokens: 0, model: '', estimate: false };
let lastUserMessage = ''; // last sent user text, drives per-turn memory search
// ── i18n State ───────────────────────────────────────────────────
let i18nStrings = {};
const I18N_FALLBACK = {
'chat.context.healthy': 'Context healthy',
'chat.context.warning': 'Approaching context limit',
'chat.context.critical': 'Context nearly full',
'chat.context.estimate': 'Estimate',
'chat.compaction.banner': 'Context nearly full — older messages may be summarized on next send',
'chat.compaction.compacted': 'Context compacted',
'chat.memory.chip': '{count} memories',
'chat.memory.empty_turn': 'No memories for this turn',
};
function t(key, params) {
let text = i18nStrings[key] != null ? i18nStrings[key] : (I18N_FALLBACK[key] != null ? I18N_FALLBACK[key] : key);
if (params) {
for (const k in params) {
text = text.split('{' + k + '}').join(String(params[k]));
}
}
return text;
}
async function loadI18nStrings() {
let locale = 'en';
try {
const stored = (typeof localStorage !== 'undefined') ? localStorage.getItem('aiui-locale') : null;
if (stored) {
locale = stored;
} else {
const lr = await fetch('/api/i18n/locale');
if (lr.ok) { const ld = await lr.json(); if (ld && ld.locale) locale = ld.locale; }
}
} catch (e) { /* fall back to English */ }
try {
const resp = await fetch('/api/i18n/strings/' + encodeURIComponent(locale));
if (resp.ok) {
const data = await resp.json();
i18nStrings = (data && data.strings) || {};
}
} catch (e) { /* keep English fallback */ }
}
// ── Floating Chat State ──────────────────────────────────────────
let chatModeConfig = { mode: 'fullpage' };
@@ -699,7 +743,10 @@ function setupResizeHandles(wrapper) {
export async function render(container) {
injectStyles();
containerRef = container;
// Load translations for ring/banner/chip (English fallback on failure)
await loadI18nStrings();
// Fetch chat mode config
try {
const configRes = await fetch('/ui-config.json');
@@ -790,15 +837,16 @@ export async function render(container) {
<textarea id="chat-input" placeholder="Type a message…" rows="1"></textarea>
<div class="chat-compose-actions">
<div class="chat-session-cost-chip" id="chat-session-cost-chip" tabindex="0"></div>
<div class="chat-ring-wrap chat-ring-healthy" id="chat-ring-wrap">
<div class="chat-ring-wrap chat-ring-healthy" id="chat-ring-wrap"
role="progressbar" aria-label="Context usage"
aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
<svg viewBox="0 0 48 48" aria-hidden="true">
<circle class="chat-ring-track" cx="24" cy="24" r="20"></circle>
<circle class="chat-ring-arc" id="chat-ring-arc" cx="24" cy="24" r="20"
stroke-dasharray="125.66" stroke-dashoffset="125.66"></circle>
</svg>
<button class="chat-ring-send" id="chat-send-btn" title="Send"
role="progressbar" aria-label="Send message"
aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">➤</button>
type="button" aria-label="Send message">➤</button>
<div class="chat-ring-tooltip" id="chat-ring-tooltip"></div>
</div>
</div>
@@ -1864,7 +1912,6 @@ function ringStateFor(pct) {
function updateContextRing(stats) {
const wrap = document.getElementById('chat-ring-wrap');
const arc = document.getElementById('chat-ring-arc');
const sendBtn = document.getElementById('chat-send-btn');
if (!wrap || !arc) return;
const used = Number(stats.used) || 0;
@@ -1888,10 +1935,8 @@ function updateContextRing(stats) {
arc.setAttribute('stroke-dasharray', RING_CIRCUMFERENCE.toFixed(2));
arc.setAttribute('stroke-dashoffset', (RING_CIRCUMFERENCE * (1 - clamped / 100)).toFixed(2));
if (sendBtn) {
sendBtn.setAttribute('aria-valuenow', String(Math.round(pct)));
sendBtn.setAttribute('aria-valuemax', '100');
}
wrap.setAttribute('aria-valuenow', String(Math.round(pct)));
wrap.setAttribute('aria-valuemax', '100');
updateRingTooltip(stats, pct);
updateCompactionBanner(pct, estimate);
@@ -1912,9 +1957,11 @@ function updateRingTooltip(stats, pct) {
['Memory', b.memory || 0],
];
const maxVal = Math.max(1, ...rows.map((r) => r[1]));
const title = stats.estimate ? 'Context breakdown (estimate)' : 'Context breakdown';
const statusKey = pct >= 85 ? 'chat.context.critical' : (pct >= 60 ? 'chat.context.warning' : 'chat.context.healthy');
const title = stats.estimate ? escapeHtml(t('chat.context.estimate')) : escapeHtml(t(statusKey));
tip.innerHTML =
'<h5>' + title + '</h5>' +
'<div class="chat-ring-tt-status">' + escapeHtml(t(statusKey)) + '</div>' +
rows.map(([lbl, val]) =>
'<div class="chat-ring-tt-row">' +
'<span class="lbl">' + lbl + '</span>' +
@@ -1942,9 +1989,7 @@ function updateCompactionBanner(pct, estimate) {
banner.classList.add('open');
banner.classList.toggle('critical', pct >= 95);
banner.setAttribute('role', pct >= 95 ? 'alert' : 'status');
textEl.textContent = pct >= 95
? 'Context ' + Math.round(pct) + '% — older messages will be summarized on next send'
: 'Context ' + Math.round(pct) + '% — older messages may be summarized on next send';
textEl.textContent = t('chat.compaction.banner');
}
function dismissCompactionBanner() {
@@ -1962,7 +2007,7 @@ function flashCompactionCompacted(data) {
banner.classList.remove('critical');
banner.setAttribute('role', 'status');
const removed = data && data.removed_tokens ? ' (' + data.removed_tokens + ' tokens freed)' : '';
textEl.textContent = 'Context compacted' + removed;
textEl.textContent = t('chat.compaction.compacted') + removed;
sessionStorage.removeItem('aiui-compaction-dismissed');
setTimeout(() => {
if (lastContextPct < 85) banner.classList.remove('open');
@@ -2009,32 +2054,46 @@ function updateMemoryChip(count, loading) {
chip.classList.remove('loading');
const n = Number(count) || 0;
if (n <= 0) {
// E5: hide chip when no memories retrieved this turn
chip.classList.remove('visible');
chip.innerHTML = '';
// E5: no memories retrieved this turn — show muted empty-turn label
chip.classList.add('visible');
chip.title = t('chat.memory.empty_turn');
chip.innerHTML = '🧠 0';
return;
}
chip.classList.add('visible');
chip.innerHTML = '🧠 ' + n + ' ' + (n === 1 ? 'memory' : 'memories');
chip.title = '';
chip.innerHTML = '🧠 ' + escapeHtml(t('chat.memory.chip', { count: n }));
}
// Per-turn memory search: count hits for the last user message, not total store.
async function refreshMemoryChip() {
const chip = document.getElementById('chat-memory-chip');
if (!chip) return;
const query = (lastUserMessage || '').trim();
if (!query) {
updateMemoryChip(0, false);
return;
}
updateMemoryChip(0, true);
try {
const endpoint = currentSessionId
? '/api/memory/session/' + encodeURIComponent(currentSessionId)
: '/api/memory';
let resp = await fetch(endpoint);
if (!resp.ok) resp = await fetch('/api/memory');
if (!resp.ok) { chip.classList.remove('loading'); chip.innerHTML = '🧠 —'; chip.classList.add('visible'); return; }
const resp = await fetch('/api/memory/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query,
limit: 10,
session_id: currentSessionId || undefined,
}),
});
if (!resp.ok) { chip.classList.remove('loading'); chip.classList.add('visible'); chip.title = ''; chip.innerHTML = '🧠 —'; return; }
const data = await resp.json();
const items = data.memories || [];
updateMemoryChip(items.length, false);
const results = data.results || [];
updateMemoryChip(results.length, false);
} catch (e) {
// E6: memory search failed — muted dash
chip.classList.remove('loading');
chip.classList.add('visible');
chip.title = '';
chip.innerHTML = '🧠 —';
}
}
@@ -2335,6 +2394,9 @@ function sendMessage() {
const content = input.value.trim();
if (!content && pendingAttachments.length === 0) return;
// Track last user text so the memory chip searches this turn's query
if (content) lastUserMessage = content;
// Generate session ID if needed
if (!currentSessionId) {
currentSessionId = crypto.randomUUID ? crypto.randomUUID() :
+13 -9
View File
@@ -49,13 +49,16 @@ templates:
routes:
- match: "/docs/**"
template: docs
"""
""",
encoding="utf-8",
)
docs_dir = tmp_path / "docs"
docs_dir.mkdir()
(docs_dir / "index.md").write_text("# Welcome\n\nHello from integration test.")
(docs_dir / "guide.md").write_text("# Guide\n\nA test guide.")
(docs_dir / "index.md").write_text(
"# Welcome\n\nHello from integration test.", encoding="utf-8"
)
(docs_dir / "guide.md").write_text("# Guide\n\nA test guide.", encoding="utf-8")
return tmp_path
@@ -94,7 +97,7 @@ class TestBuildCommand:
["build", "--config", str(project_dir / "aiui.template.yaml"), "--output", str(output)],
)
ui_config = json.loads((output / "ui-config.json").read_text())
ui_config = json.loads((output / "ui-config.json").read_text(encoding="utf-8"))
assert ui_config["site"]["title"] == "Integration Test Site"
assert "header_main" in ui_config["components"]
assert "footer_main" in ui_config["components"]
@@ -111,12 +114,12 @@ class TestBuildCommand:
],
)
content = (output / "ui-config.json").read_text()
content = (output / "ui-config.json").read_text(encoding="utf-8")
assert "\n" not in content # Minified = no newlines
def test_build_invalid_config(self, tmp_path: Path):
config = tmp_path / "bad.yaml"
config.write_text("not: valid: yaml: config")
config.write_text("not: valid: yaml: config", encoding="utf-8")
result = runner.invoke(
app,
["build", "--config", str(config), "--output", str(tmp_path / "out")],
@@ -141,7 +144,8 @@ templates:
routes:
- match: "/docs/**"
template: docs
"""
""",
encoding="utf-8",
)
result = runner.invoke(
app,
@@ -215,8 +219,8 @@ class TestFullPipeline:
assert (output / "index.html").exists()
# Verify content
ui_config = json.loads((output / "ui-config.json").read_text())
ui_config = json.loads((output / "ui-config.json").read_text(encoding="utf-8"))
assert ui_config["site"]["title"] == "Integration Test Site"
routes = json.loads((output / "route-manifest.json").read_text())
routes = json.loads((output / "route-manifest.json").read_text(encoding="utf-8"))
assert len(routes["routes"]) >= 1
+6 -2
View File
@@ -488,12 +488,16 @@ class TestTelemetryAPI:
class TestTracingAPI:
def test_list_traces(self, client):
r = client.get("/api/tracing")
r = client.get("/api/traces")
assert r.status_code == 200
data = r.json()
assert isinstance(data, (list, dict))
def test_tracing_status(self, client):
r = client.get("/api/tracing/status")
r = client.get("/api/traces/status")
assert r.status_code == 200
data = r.json()
assert "status" in data or "feature" in data
# ===========================================================================
+1 -1
View File
@@ -305,7 +305,7 @@ class TestAudioSessionManagement:
session = _audio_sessions[session_id]
assert session["chunks_received"] == 1
assert session["bytes_received"] == len(test_data)
assert session["last_chunk_at"] > session["started_at"]
assert session["last_chunk_at"] >= session["started_at"]
# Check global stats were updated
assert _audio_stats["total_chunks"] == 1
+24 -12
View File
@@ -224,25 +224,37 @@ def test_constant_time_comparison():
def test_timing_attack_resistance():
"""Test timing attack resistance - all incorrect tokens take similar time."""
"""Test timing attack resistance - all incorrect tokens take similar time.
Timing is inherently noisy: a single sample per token trips on scheduler
spikes when the full suite runs under CPU contention (issue #256). We take
several samples per token and compare the *median* time, which is resistant
to transient spikes, so the test measures the constant-time property rather
than system load.
"""
correct = "secret123456789012345678901234567890"
wrong_same_length = "wrongg123456789012345678901234567890"
wrong_diff_length = "wrong"
tokens = [wrong_same_length, wrong_diff_length, ""]
# Measure times for different wrong tokens
times = []
for wrong_token in [wrong_same_length, wrong_diff_length, ""]:
start = time.perf_counter()
for _ in range(1000): # Multiple iterations for better timing measurement
_constant_time_eq(correct, wrong_token)
end = time.perf_counter()
times.append(end - start)
def _median_time(wrong_token: str) -> float:
samples = []
for _ in range(7):
start = time.perf_counter()
for _ in range(1000):
_constant_time_eq(correct, wrong_token)
samples.append(time.perf_counter() - start)
samples.sort()
return samples[len(samples) // 2]
# Times should be similar (within reasonable variance)
# This is a best-effort test - timing can be affected by system load
# Warm up so JIT/cache effects do not skew the first token's samples.
_median_time(correct)
times = [_median_time(token) for token in tokens]
# Median times should be similar (all wrong tokens take comparable time).
avg_time = sum(times) / len(times)
for t in times:
# Allow up to 50% variance due to system noise
assert abs(t - avg_time) / avg_time < 0.5, f"Timing variance too high: {times}"
+2 -2
View File
@@ -22,7 +22,7 @@ EXPLORER_VIEW = _FRONTEND / "views" / "explorer.js"
def _view() -> str:
return CHANNELS_VIEW.read_text()
return CHANNELS_VIEW.read_text(encoding="utf-8")
class TestAddChannelControls:
@@ -91,6 +91,6 @@ class TestEmptyState:
class TestExplorerPreset:
def test_explorer_has_add_channel_post(self):
src = EXPLORER_VIEW.read_text()
src = EXPLORER_VIEW.read_text(encoding="utf-8")
assert "Add Channel" in src
assert "env:TELEGRAM_BOT_TOKEN" in src
+7 -7
View File
@@ -25,13 +25,13 @@ CHAT_JS = FRONTEND / "views" / "chat.js"
class TestSelectPageShellError:
def test_shell_error_helper_defined(self):
src = DASHBOARD_JS.read_text()
src = DASHBOARD_JS.read_text(encoding="utf-8")
assert "function showDashboardInitError(" in src
assert "'alert'" in src
assert "db-error" in src
def test_select_page_calls_shell_error_on_missing_main(self):
src = DASHBOARD_JS.read_text()
src = DASHBOARD_JS.read_text(encoding="utf-8")
idx = src.find("const main = document.getElementById('db-main-content')")
assert idx != -1
region = src[idx:idx + 400]
@@ -41,16 +41,16 @@ class TestSelectPageShellError:
class TestChatOfflineMessaging:
def test_toast_imported(self):
src = CHAT_JS.read_text()
src = CHAT_JS.read_text(encoding="utf-8")
assert "import { showToast } from '../toast.js';" in src
def test_offline_hint_in_fallback(self):
src = CHAT_JS.read_text()
src = CHAT_JS.read_text(encoding="utf-8")
assert "navigator.onLine === false" in src
assert "You are offline. " in src
def test_fallback_catch_shows_toast(self):
src = CHAT_JS.read_text()
src = CHAT_JS.read_text(encoding="utf-8")
idx = src.find("fetch('/api/chat/send'")
assert idx != -1
region = src[idx:idx + 900]
@@ -62,7 +62,7 @@ class TestChatOfflineMessaging:
class TestSystemErrorRoleAlert:
def test_append_message_sets_role_alert(self):
src = CHAT_JS.read_text()
src = CHAT_JS.read_text(encoding="utf-8")
idx = src.find("function appendMessage(")
assert idx != -1
region = src[idx:idx + 700]
@@ -72,7 +72,7 @@ class TestSystemErrorRoleAlert:
class TestWebSocketSendGuard:
def test_ws_send_wrapped_in_try_catch(self):
src = CHAT_JS.read_text()
src = CHAT_JS.read_text(encoding="utf-8")
idx = src.find("ws.send(JSON.stringify(payload))")
assert idx != -1
before = src[max(0, idx - 120):idx]
+6 -6
View File
@@ -50,28 +50,28 @@ class TestCodeStudioFrontend:
assert (VIEWS / "code-studio.js").is_file()
def test_view_exports_render(self):
src = (VIEWS / "code-studio.js").read_text()
src = (VIEWS / "code-studio.js").read_text(encoding="utf-8")
assert "export async function render" in src
assert "export function cleanup" in src
def test_view_calls_execute_api(self):
src = (VIEWS / "code-studio.js").read_text()
src = (VIEWS / "code-studio.js").read_text(encoding="utf-8")
assert "/api/code/execute" in src
assert "/api/code/languages" in src
def test_no_browser_eval(self):
src = (VIEWS / "code-studio.js").read_text()
src = (VIEWS / "code-studio.js").read_text(encoding="utf-8")
assert "eval(" not in src
def test_registered_in_dashboard(self):
src = (PLUGINS / "dashboard.js").read_text()
src = (PLUGINS / "dashboard.js").read_text(encoding="utf-8")
assert "'code-studio'" in src
assert "code-studio.js" in src
def test_send_to_agent_event(self):
src = (VIEWS / "code-studio.js").read_text()
src = (VIEWS / "code-studio.js").read_text(encoding="utf-8")
assert "aiui:prefill-composer" in src
def test_chat_has_prefill_listener(self):
src = (VIEWS / "chat.js").read_text()
src = (VIEWS / "chat.js").read_text(encoding="utf-8")
assert "aiui:prefill-composer" in src
+1 -1
View File
@@ -22,7 +22,7 @@ DASHBOARD_JS = FRONTEND / "dashboard.js"
class TestCommandPalette:
def _src(self) -> str:
return DASHBOARD_JS.read_text()
return DASHBOARD_JS.read_text(encoding="utf-8")
def test_open_command_palette_exposed_globally(self):
src = self._src()
+19 -19
View File
@@ -86,7 +86,7 @@ class TestCompilerUiConfig:
result = compiler.compile(tmp_path / "output")
assert result.success is True
ui_config = json.loads((tmp_path / "output" / "ui-config.json").read_text())
ui_config = json.loads((tmp_path / "output" / "ui-config.json").read_text(encoding="utf-8"))
assert ui_config["site"]["title"] == "Test Site"
assert "templates" in ui_config
assert "docs" in ui_config["templates"]
@@ -96,7 +96,7 @@ class TestCompilerUiConfig:
result = compiler.compile(tmp_path / "output")
assert result.success is True
ui_config = json.loads((tmp_path / "output" / "ui-config.json").read_text())
ui_config = json.loads((tmp_path / "output" / "ui-config.json").read_text(encoding="utf-8"))
assert "header_main" in ui_config["components"]
assert ui_config["components"]["header_main"]["props"]["logoText"] == "MySite"
assert "footer_main" in ui_config["components"]
@@ -107,7 +107,7 @@ class TestCompilerUiConfig:
result = compiler.compile(tmp_path / "output")
assert result.success is True
ui_config = json.loads((tmp_path / "output" / "ui-config.json").read_text())
ui_config = json.loads((tmp_path / "output" / "ui-config.json").read_text(encoding="utf-8"))
docs_template = ui_config["templates"]["docs"]
assert docs_template["slots"]["header"]["ref"] == "header_main"
assert docs_template["slots"]["footer"]["ref"] == "footer_main"
@@ -123,7 +123,7 @@ class TestCompilerRouteManifest:
assert "route-manifest.json" in result.files
manifest = json.loads(
(tmp_path / "output" / "route-manifest.json").read_text()
(tmp_path / "output" / "route-manifest.json").read_text(encoding="utf-8")
)
assert len(manifest["routes"]) == 1
assert manifest["routes"][0]["pattern"] == "/docs/**"
@@ -147,7 +147,7 @@ class TestCompilerRouteManifest:
assert result.success is True
manifest = json.loads(
(tmp_path / "output" / "route-manifest.json").read_text()
(tmp_path / "output" / "route-manifest.json").read_text(encoding="utf-8")
)
# First route in list gets highest priority
assert manifest["routes"][0]["priority"] > manifest["routes"][1]["priority"]
@@ -200,7 +200,7 @@ class TestCompilerOutputFiles:
compiler = Compiler(minimal_config, base_path=tmp_path)
compiler.compile(tmp_path / "output", minify=True)
content = (tmp_path / "output" / "ui-config.json").read_text()
content = (tmp_path / "output" / "ui-config.json").read_text(encoding="utf-8")
# Minified JSON should not have newlines
assert "\n" not in content
@@ -208,7 +208,7 @@ class TestCompilerOutputFiles:
compiler = Compiler(minimal_config, base_path=tmp_path)
compiler.compile(tmp_path / "output", minify=False)
content = (tmp_path / "output" / "ui-config.json").read_text()
content = (tmp_path / "output" / "ui-config.json").read_text(encoding="utf-8")
# Non-minified JSON should have newlines
assert "\n" in content
@@ -228,7 +228,7 @@ class TestCompilerDocsContent:
result = compiler.compile(tmp_path / "output")
assert "docs-nav.json" in result.files
nav = json.loads((tmp_path / "output" / "docs-nav.json").read_text())
nav = json.loads((tmp_path / "output" / "docs-nav.json").read_text(encoding="utf-8"))
assert "items" in nav
def test_no_docs_nav_without_content(self, minimal_config: Config, tmp_path: Path):
@@ -291,7 +291,7 @@ class TestCompilerThemeCss:
assert result.success is True
theme_css = tmp_path / "output" / "assets" / "theme.css"
assert theme_css.exists(), "assets/theme.css must be generated"
content = theme_css.read_text()
content = theme_css.read_text(encoding="utf-8")
assert ":root {" in content
assert "--radius:" in content
@@ -340,7 +340,7 @@ class TestInjectThemeCss:
inject_theme_css(tmp_path, preset="zinc", dark_mode=True, radius="0.5rem")
theme_file = tmp_path / "assets" / "theme.css"
assert theme_file.exists()
content = theme_file.read_text()
content = theme_file.read_text(encoding="utf-8")
assert ":root {" in content
def test_creates_assets_dir(self, tmp_path: Path):
@@ -367,7 +367,7 @@ class TestThemePipelineIntegration:
assert result.success is True
assert "assets/theme.css" in result.files
theme_css = (tmp_path / "output" / "assets" / "theme.css").read_text()
theme_css = (tmp_path / "output" / "assets" / "theme.css").read_text(encoding="utf-8")
assert ":root {" in theme_css
assert ".dark {" in theme_css
assert "--radius: 1rem" in theme_css # xl = 1rem
@@ -386,7 +386,7 @@ class TestThemePipelineIntegration:
result = compiler.compile(tmp_path / "output")
assert result.success is True
theme_css = (tmp_path / "output" / "assets" / "theme.css").read_text()
theme_css = (tmp_path / "output" / "assets" / "theme.css").read_text(encoding="utf-8")
assert ":root {" in theme_css
assert ".dark" not in theme_css
@@ -405,7 +405,7 @@ class TestThemePipelineIntegration:
assert result.success is True
ui_config = json.loads(
(tmp_path / "output" / "ui-config.json").read_text()
(tmp_path / "output" / "ui-config.json").read_text(encoding="utf-8")
)
theme = ui_config["site"]["theme"]
assert theme["preset"] == "blue"
@@ -426,7 +426,7 @@ class TestThemePipelineIntegration:
theme_css_path = tmp_path / "output" / "assets" / "theme.css"
assert theme_css_path.exists(), "theme.css must always be generated"
content = theme_css_path.read_text()
content = theme_css_path.read_text(encoding="utf-8")
assert ":root {" in content
assert "--radius:" in content
@@ -444,7 +444,7 @@ class TestThemePipelineIntegration:
result = compiler.compile(tmp_path / "output")
assert result.success is True
index_html = (tmp_path / "output" / "index.html").read_text()
index_html = (tmp_path / "output" / "index.html").read_text(encoding="utf-8")
assert "classList.add('dark')" not in index_html
def test_dark_mode_antiflicker_has_dark_class(self, tmp_path: Path):
@@ -461,7 +461,7 @@ class TestThemePipelineIntegration:
result = compiler.compile(tmp_path / "output")
assert result.success is True
index_html = (tmp_path / "output" / "index.html").read_text()
index_html = (tmp_path / "output" / "index.html").read_text(encoding="utf-8")
assert "classList.add('dark')" in index_html
@@ -514,7 +514,7 @@ class TestCompositionResolver:
# Load ui-config.json and verify auto-bridging
ui_config_path = tmp_path / "output" / "ui-config.json"
ui_config = json.loads(ui_config_path.read_text())
ui_config = json.loads(ui_config_path.read_text(encoding="utf-8"))
docs_template = ui_config["templates"]["docs"]
assert "zones" in docs_template
@@ -556,7 +556,7 @@ class TestCompositionResolver:
# Load ui-config.json and verify no auto-bridging occurred
ui_config_path = tmp_path / "output" / "ui-config.json"
ui_config = json.loads(ui_config_path.read_text())
ui_config = json.loads(ui_config_path.read_text(encoding="utf-8"))
docs_template = ui_config["templates"]["docs"]
# Should not have zones for non-FlexibleLayout
@@ -589,7 +589,7 @@ class TestCompositionResolver:
# Load ui-config.json and verify no duplicate components
ui_config_path = tmp_path / "output" / "ui-config.json"
ui_config = json.loads(ui_config_path.read_text())
ui_config = json.loads(ui_config_path.read_text(encoding="utf-8"))
docs_template = ui_config["templates"]["docs"]
zones = docs_template["zones"]
+5 -5
View File
@@ -30,7 +30,7 @@ class TestCompilerChatOutput:
result = compiler.compile(tmp_path)
assert result.success
ui_config = json.loads((tmp_path / "ui-config.json").read_text())
ui_config = json.loads((tmp_path / "ui-config.json").read_text(encoding="utf-8"))
assert ui_config["style"] == "chat"
def test_layout_in_ui_config(self, tmp_path: Path):
@@ -43,7 +43,7 @@ class TestCompilerChatOutput:
result = compiler.compile(tmp_path)
assert result.success
ui_config = json.loads((tmp_path / "ui-config.json").read_text())
ui_config = json.loads((tmp_path / "ui-config.json").read_text(encoding="utf-8"))
assert ui_config["layout"]["mode"] == "bottom-right"
assert ui_config["layout"]["width"] == "400px"
assert ui_config["layout"]["height"] == "600px"
@@ -73,7 +73,7 @@ class TestCompilerChatOutput:
result = compiler.compile(tmp_path)
assert result.success
ui_config = json.loads((tmp_path / "ui-config.json").read_text())
ui_config = json.loads((tmp_path / "ui-config.json").read_text(encoding="utf-8"))
assert ui_config["chat"]["enabled"] is True
assert ui_config["chat"]["name"] == "AI Assistant"
assert len(ui_config["chat"]["starters"]) == 1
@@ -96,7 +96,7 @@ class TestCompilerChatOutput:
result = compiler.compile(tmp_path)
assert result.success
ui_config = json.loads((tmp_path / "ui-config.json").read_text())
ui_config = json.loads((tmp_path / "ui-config.json").read_text(encoding="utf-8"))
assert ui_config["auth"]["enabled"] is True
assert "password" in ui_config["auth"]["providers"]
assert "google" in ui_config["auth"]["providers"]
@@ -128,7 +128,7 @@ class TestCompilerChatOutput:
result = compiler.compile(tmp_path)
assert result.success
ui_config = json.loads((tmp_path / "ui-config.json").read_text())
ui_config = json.loads((tmp_path / "ui-config.json").read_text(encoding="utf-8"))
assert len(ui_config["widgets"]) == 2
assert ui_config["widgets"][0]["type"] == "slider"
assert ui_config["widgets"][0]["name"] == "temperature"
+2 -2
View File
@@ -24,7 +24,7 @@ DASHBOARD_JS = (
@pytest.fixture
def dashboard_source():
"""Read dashboard.js source."""
return DASHBOARD_JS.read_text()
return DASHBOARD_JS.read_text(encoding="utf-8")
class TestComponentRegistry:
@@ -68,7 +68,7 @@ class TestPythonJSComponentParity:
Path(__file__).resolve().parents[2]
/ "src" / "praisonaiui" / "ui.py"
)
source = ui_py.read_text()
source = ui_py.read_text(encoding="utf-8")
# Extract all "type": "xxx" patterns from ui.py
py_types = set(re.findall(r'"type":\s*"(\w+)"', source))
+1 -1
View File
@@ -111,7 +111,7 @@ class TestBuiltinSchemaCoverage:
ui_src = (
Path(__file__).resolve().parents[2]
/ "src" / "praisonaiui" / "ui.py"
).read_text()
).read_text(encoding="utf-8")
py_types = set(re.findall(r'"type":\s*"(\w+)"', ui_src))
schemas = srv.get_component_schemas()
missing = py_types - set(schemas)
+8 -8
View File
@@ -136,47 +136,47 @@ class TestRingThresholds:
class TestChatJsComponents:
def test_ring_dom_present(self):
src = CHAT_JS.read_text()
src = CHAT_JS.read_text(encoding="utf-8")
assert 'id="chat-ring-wrap"' in src
assert 'id="chat-ring-arc"' in src
assert 'id="chat-ring-tooltip"' in src
def test_send_button_inside_ring_preserved_id(self):
src = CHAT_JS.read_text()
src = CHAT_JS.read_text(encoding="utf-8")
assert 'class="chat-ring-send" id="chat-send-btn"' in src
assert 'aria-valuenow' in src
assert 'aria-valuemax' in src
def test_compaction_banner_present(self):
src = CHAT_JS.read_text()
src = CHAT_JS.read_text(encoding="utf-8")
assert 'id="chat-compaction-banner"' in src
assert "function updateCompactionBanner(" in src
assert "function dismissCompactionBanner(" in src
def test_memory_chip_present(self):
src = CHAT_JS.read_text()
src = CHAT_JS.read_text(encoding="utf-8")
assert 'id="chat-memory-chip"' in src
assert "function refreshMemoryChip(" in src
def test_ring_state_machine_thresholds(self):
src = CHAT_JS.read_text()
src = CHAT_JS.read_text(encoding="utf-8")
assert "function ringStateFor(" in src
assert "pct >= 95" in src
assert "pct >= 85" in src
assert "pct >= 60" in src
def test_ws_context_events_handled(self):
src = CHAT_JS.read_text()
src = CHAT_JS.read_text(encoding="utf-8")
assert "case 'context_update':" in src
assert "case 'context_compacted':" in src
def test_poll_fallback_and_debounce(self):
src = CHAT_JS.read_text()
src = CHAT_JS.read_text(encoding="utf-8")
assert "function startContextPoll(" in src
assert "function scheduleContextRefresh(" in src
def test_existing_memory_panel_and_metrics_preserved(self):
src = CHAT_JS.read_text()
src = CHAT_JS.read_text(encoding="utf-8")
# Regression: do not remove existing shipped elements
assert 'id="chat-memory-panel"' in src
assert 'id="chat-memory-btn"' in src
+126
View File
@@ -0,0 +1,126 @@
"""Unit tests for issue #208 — i18n wiring, per-turn memory search, and a11y.
Covers post-merge gaps left by PR #206 on the chat context ring UI:
* Gap A — ring/banner/chip strings consume i18n via a JS ``t()`` helper.
* Gap B — memory chip uses ``POST /api/memory/search`` (per-turn hits),
not ``GET /api/memory`` (total store).
* Gap C — ``role="progressbar"`` lives on the ring wrapper, not the send button.
"""
from __future__ import annotations
from pathlib import Path
from praisonaiui.features.i18n import JSONLocaleManager
FRONTEND = (
Path(__file__).resolve().parents[2]
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
)
CHAT_JS = FRONTEND / "views" / "chat.js"
def _src() -> str:
return CHAT_JS.read_text(encoding="utf-8")
class TestGapAI18nWiring:
def test_i18n_loader_present(self):
src = _src()
assert "async function loadI18nStrings(" in src
assert "/api/i18n/strings/" in src
assert "await loadI18nStrings()" in src
def test_t_helper_with_fallback(self):
src = _src()
assert "function t(key, params)" in src
assert "I18N_FALLBACK" in src
def test_i18n_used_at_least_four_times(self):
# E2E regression bar from the issue: i18n key lookups >= 4
src = _src()
assert src.count("t('chat.") >= 4
def test_banner_uses_i18n_key(self):
src = _src()
assert "t('chat.compaction.banner')" in src
def test_compacted_flash_uses_i18n_key(self):
src = _src()
assert "t('chat.compaction.compacted')" in src
def test_memory_chip_uses_i18n_key(self):
src = _src()
assert "t('chat.memory.chip'" in src
assert "t('chat.memory.empty_turn')" in src
def test_tooltip_uses_context_status_keys(self):
src = _src()
assert "chat.context.critical" in src
assert "chat.context.warning" in src
assert "chat.context.healthy" in src
assert "t('chat.context.estimate')" in src
class TestGapBMemorySearch:
def test_search_endpoint_used(self):
# E2E regression bar: memory/search present >= 1
src = _src()
assert "/api/memory/search" in src
def test_search_is_post_with_query(self):
src = _src()
assert "method: 'POST'" in src
assert "lastUserMessage" in src
assert "query," in src
def test_chip_counts_results_not_total(self):
src = _src()
assert "data.results || []" in src
assert "results.length" in src
def test_last_user_message_tracked_on_send(self):
src = _src()
assert "lastUserMessage = content" in src
class TestGapCAccessibility:
def test_progressbar_role_on_wrapper(self):
src = _src()
# Wrapper div carries the progressbar role + aria values
assert 'id="chat-ring-wrap"' in src
wrap_idx = src.index('id="chat-ring-wrap"')
wrap_slice = src[wrap_idx:wrap_idx + 260]
assert 'role="progressbar"' in wrap_slice
assert "aria-valuenow" in wrap_slice
assert "aria-valuemax" in wrap_slice
def test_send_button_is_button_not_progressbar(self):
src = _src()
btn_idx = src.index('id="chat-send-btn"')
btn_slice = src[btn_idx:btn_idx + 160]
assert 'type="button"' in btn_slice
assert 'role="progressbar"' not in btn_slice
assert 'aria-label="Send message"' in btn_slice
def test_aria_updates_target_wrapper(self):
src = _src()
assert "wrap.setAttribute('aria-valuenow'" in src
class TestI18nMemoryChipLocales:
def test_chip_translation_interpolates_count_all_locales(self):
mgr = JSONLocaleManager()
for locale, word in (("en", "memories"), ("es", "recuerdos"), ("fr", "souvenirs")):
out = mgr.t("chat.memory.chip", locale=locale, count=2)
assert "2" in out
assert word in out
def test_banner_localised(self):
mgr = JSONLocaleManager()
assert mgr.t("chat.compaction.banner", locale="es") != mgr.t(
"chat.compaction.banner", locale="en"
)
assert mgr.t("chat.compaction.banner", locale="fr") != mgr.t(
"chat.compaction.banner", locale="en"
)
@@ -27,7 +27,7 @@ DASHBOARD_JS = (
def _style() -> str:
src = DASHBOARD_JS.read_text()
src = DASHBOARD_JS.read_text(encoding="utf-8")
m = re.search(r"const DASHBOARD_STYLE = `(.*?)`;", src, re.DOTALL)
assert m, "DASHBOARD_STYLE template literal not found"
return m.group(1)
@@ -21,7 +21,7 @@ DASHBOARD_JS = FRONTEND / "dashboard.js"
class TestSelectPageGuard:
def _select_page_body(self) -> str:
src = DASHBOARD_JS.read_text()
src = DASHBOARD_JS.read_text(encoding="utf-8")
m = re.search(
r"async function selectPage\(pageId\)\s*\{(.*?)^}",
src, re.DOTALL | re.MULTILINE,
@@ -56,7 +56,7 @@ class TestSelectPageGuard:
assert "console.error" in m.group(1)
def test_show_dashboard_init_error_helper_present(self):
src = DASHBOARD_JS.read_text()
src = DASHBOARD_JS.read_text(encoding="utf-8")
m = re.search(
r"function showDashboardInitError\(message\)\s*\{(.*?)^}",
src, re.DOTALL | re.MULTILINE,
+2 -2
View File
@@ -23,7 +23,7 @@ GENERATOR = ROOT / "scripts" / "patch_dashboard_modular.py"
class TestDashboardShellOrder:
def _build_body(self) -> str:
src = DASHBOARD_JS.read_text()
src = DASHBOARD_JS.read_text(encoding="utf-8")
m = re.search(
r"function buildDashboard\([^)]*\)\s*\{(.*?)^}",
src, re.DOTALL | re.MULTILINE,
@@ -51,7 +51,7 @@ class TestDashboardShellOrder:
assert m, "shellHeader insert must be guarded by children.length"
def test_generator_emits_main_before_insert_before(self):
src = GENERATOR.read_text()
src = GENERATOR.read_text(encoding="utf-8")
append_idx = src.find("root.appendChild(main);\n const shellHeader")
insert_idx = src.find("root.insertBefore(shellHeader, main)")
assert append_idx != -1, (
+70
View File
@@ -0,0 +1,70 @@
"""Tests for the shared examples console helper.
These verify that examples degrade emoji to ASCII on legacy Windows code pages
(cp1252) instead of raising ``UnicodeEncodeError`` while preserving emoji on
UTF-aware terminals, mirroring the CLI ``_icon``/``_supports_unicode`` pattern.
"""
import io
import os
import sys
from unittest import mock
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "examples", "python", "_shared"))
from console import icon, safe_print, supports_unicode
class TestSupportsUnicode:
def test_utf8_encoding_is_supported(self):
mock_stdout = mock.MagicMock()
mock_stdout.encoding = "utf-8"
with mock.patch("sys.stdout", mock_stdout):
assert supports_unicode() is True
def test_cp1252_encoding_is_not_supported(self):
mock_stdout = mock.MagicMock()
mock_stdout.encoding = "cp1252"
with mock.patch("sys.stdout", mock_stdout):
assert supports_unicode() is False
def test_missing_encoding_falls_back_to_ascii(self):
mock_stdout = mock.MagicMock()
mock_stdout.encoding = None
with mock.patch("sys.stdout", mock_stdout):
assert supports_unicode() is False
class TestIcon:
def test_returns_symbol_on_utf8(self):
mock_stdout = mock.MagicMock()
mock_stdout.encoding = "utf-8"
with mock.patch("sys.stdout", mock_stdout):
assert icon("🚀", "[START]") == "🚀"
def test_returns_fallback_on_cp1252(self):
mock_stdout = mock.MagicMock()
mock_stdout.encoding = "cp1252"
with mock.patch("sys.stdout", mock_stdout):
assert icon("🚀", "[START]") == "[START]"
class TestSafePrint:
def test_never_raises_on_cp1252(self):
buffer = io.BytesIO()
stream = io.TextIOWrapper(buffer, encoding="cp1252", errors="strict")
with mock.patch("sys.stdout", stream):
safe_print("🚀 Starting")
stream.flush()
assert buffer.getvalue()
def test_preserves_unicode_on_utf8(self):
buffer = io.StringIO()
with mock.patch("sys.stdout", buffer):
safe_print("🚀 Starting")
assert "🚀" in buffer.getvalue()
def test_respects_sep(self):
buffer = io.StringIO()
with mock.patch("sys.stdout", buffer):
safe_print("a", "b", sep="-")
assert buffer.getvalue().strip() == "a-b"
+11 -11
View File
@@ -154,13 +154,13 @@ class TestFinOpsValidation:
class TestHelpersJs:
def test_donut_and_strip_helpers_present(self):
src = HELPERS_JS.read_text()
src = HELPERS_JS.read_text(encoding="utf-8")
assert "export function MiniDonutSVG(" in src
assert "export function costStripHTML(" in src
assert "export function budgetBannerHTML(" in src
def test_math_helpers_present(self):
src = HELPERS_JS.read_text()
src = HELPERS_JS.read_text(encoding="utf-8")
assert "export function computeBudgetPct(" in src
assert "export function budgetLevelFor(" in src
assert "export function sumTodayTokens(" in src
@@ -168,53 +168,53 @@ class TestHelpersJs:
assert "export function formatCost(" in src
def test_donut_empty_state_accessible(self):
src = HELPERS_JS.read_text()
src = HELPERS_JS.read_text(encoding="utf-8")
assert 'aria-label="No model data"' in src
def test_strip_navigates_and_is_region(self):
src = HELPERS_JS.read_text()
src = HELPERS_JS.read_text(encoding="utf-8")
assert 'data-nav="usage"' in src
assert 'role="region"' in src
class TestOverviewJs:
def test_finops_imports_and_render(self):
src = OVERVIEW_JS.read_text()
src = OVERVIEW_JS.read_text(encoding="utf-8")
assert "costStripHTML" in src
assert "budgetBannerHTML" in src
assert "function renderFinOps(" in src
assert 'id="ov-finops"' in src
def test_fetches_models_and_config(self):
src = OVERVIEW_JS.read_text()
src = OVERVIEW_JS.read_text(encoding="utf-8")
assert "/api/usage/models" in src
assert "/api/config/runtime" in src
def test_graceful_degrade_state_machine(self):
src = OVERVIEW_JS.read_text()
src = OVERVIEW_JS.read_text(encoding="utf-8")
assert "function finopsState(" in src
assert "finops.enabled === false" in src
class TestChatJs:
def test_session_cost_chip_present(self):
src = CHAT_JS.read_text()
src = CHAT_JS.read_text(encoding="utf-8")
assert 'id="chat-session-cost-chip"' in src
assert "function updateSessionCostChip(" in src
def test_finops_banner_present(self):
src = CHAT_JS.read_text()
src = CHAT_JS.read_text(encoding="utf-8")
assert 'id="chat-finops-banner"' in src
assert "function updateFinopsBanner(" in src
assert "function loadFinopsConfig(" in src
def test_chip_updates_on_run_completed(self):
src = CHAT_JS.read_text()
src = CHAT_JS.read_text(encoding="utf-8")
assert "updateFinopsBanner()" in src
assert "updateSessionCostChip(stats)" in src
def test_context_ring_regression_preserved(self):
src = CHAT_JS.read_text()
src = CHAT_JS.read_text(encoding="utf-8")
assert 'id="chat-ring-wrap"' in src
assert "function ringStateFor(" in src
assert 'class="chat-ring-send" id="chat-send-btn"' in src
+1 -1
View File
@@ -108,7 +108,7 @@ class TestFormInputDataLabel:
/ "src" / "praisonaiui" / "templates" / "frontend"
/ "plugins" / "dashboard.js"
)
return p.read_text()
return p.read_text(encoding="utf-8")
@pytest.mark.parametrize("renderer", [
"renderTextInput",
+2 -2
View File
@@ -84,7 +84,7 @@ class TestRenderersEmitName:
via `el.name` (which has priority over `el.dataset.label`)."""
def _src(self):
return DASHBOARD_JS.read_text()
return DASHBOARD_JS.read_text(encoding="utf-8")
@pytest.mark.parametrize("renderer", [
"renderTextInput",
@@ -117,7 +117,7 @@ class TestSubmitCollectorPriority:
"""renderFormAction submit handler must prefer `name` over `data-label`."""
def test_name_has_priority_over_label(self):
source = DASHBOARD_JS.read_text()
source = DASHBOARD_JS.read_text(encoding="utf-8")
m = re.search(
r"function renderFormAction\(comp\)\s*\{(.*?)^}",
source, re.DOTALL | re.MULTILINE,
+2 -2
View File
@@ -16,7 +16,7 @@ class TestFrontendNavigation:
index_path = Path("src/praisonaiui/templates/frontend/index.html")
assert index_path.exists(), "index.html should exist"
content = index_path.read_text()
content = index_path.read_text(encoding="utf-8")
assert "/assets/index.js" in content, "Should reference index.js"
assert "/assets/index.css" in content, "Should reference index.css"
@@ -26,7 +26,7 @@ class TestFrontendNavigation:
if bundle_path.exists():
# Check if the bundle contains popstate handling code
# Note: The bundle is minified, so we check for the event name
content = bundle_path.read_text()
content = bundle_path.read_text(encoding="utf-8")
assert "popstate" in content, "Bundle should contain popstate event handler"
def test_title_template_handling(self):
+5 -5
View File
@@ -23,7 +23,7 @@ SERVER = _ROOT / "src" / "praisonaiui" / "server.py"
def _inbox() -> str:
return INBOX_VIEW.read_text()
return INBOX_VIEW.read_text(encoding="utf-8")
class TestThreePaneShell:
@@ -109,21 +109,21 @@ class TestEmptyState:
class TestRegistration:
def test_route_registered_in_dashboard(self):
src = DASHBOARD.read_text()
src = DASHBOARD.read_text(encoding="utf-8")
assert "inbox:" in src
assert "/plugins/views/inbox.js" in src
def test_page_registered_in_server(self):
src = SERVER.read_text()
src = SERVER.read_text(encoding="utf-8")
assert '"id": "inbox"' in src
assert "Channel Inbox" in src
def test_channels_links_to_inbox(self):
assert 'href="/inbox"' in CHANNELS_VIEW.read_text()
assert 'href="/inbox"' in CHANNELS_VIEW.read_text(encoding="utf-8")
class TestSessionsPlatformField:
def test_sessions_list_surfaces_platform(self):
src = SESSIONS_EXT.read_text()
src = SESSIONS_EXT.read_text(encoding="utf-8")
assert '"platform"' in src
assert '"channel_id"' in src
+194
View File
@@ -0,0 +1,194 @@
"""Unit tests for knowledge embedding fallback + degraded-mode reporting."""
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import pytest
from starlette.testclient import TestClient
from praisonaiui.features import _features, register_feature
from praisonaiui.features import knowledge as knowledge_mod
from praisonaiui.features.knowledge import (
KnowledgeFeature,
SDKKnowledgeManager,
SimpleKnowledgeManager,
_is_embedding_unavailable,
)
from praisonaiui.features.openai_api import (
OpenAIAPIFeature,
_default_embedding_model,
_embedding_fallback_models,
_is_model_not_found,
)
from praisonaiui.server import create_app
class _FakeSDK:
def __init__(self, search_error: BaseException | None = None) -> None:
self._search_error = search_error
def search(self, query, limit=1, user_id=None):
if query == "__probe__":
return []
if self._search_error is not None:
raise self._search_error
return []
def test_is_model_not_found_detects_403():
assert _is_model_not_found(Exception("Project does not have access to model x"))
assert _is_model_not_found(Exception("litellm.NotFoundError: nope"))
assert not _is_model_not_found(Exception("timeout"))
def test_is_embedding_unavailable_detects_403():
assert _is_embedding_unavailable(Exception("model_not_found"))
assert not _is_embedding_unavailable(Exception("connection reset"))
def test_default_embedding_model_env(monkeypatch):
monkeypatch.delenv("AIUI_EMBEDDING_MODEL", raising=False)
monkeypatch.delenv("OPENAI_EMBEDDING_MODEL", raising=False)
assert _default_embedding_model() == "text-embedding-3-small"
monkeypatch.setenv("AIUI_EMBEDDING_MODEL", "text-embedding-ada-002")
assert _default_embedding_model() == "text-embedding-ada-002"
def test_fallback_models_env(monkeypatch):
monkeypatch.setenv("AIUI_EMBEDDING_FALLBACK_MODELS", "a, b ,,c")
assert _embedding_fallback_models() == ["a", "b", "c"]
def test_simple_manager_reports_local_mode():
mgr = SimpleKnowledgeManager()
mgr.store("hello world")
mgr.search("hello")
assert mgr.last_search_meta()["mode"] == "local"
assert mgr.health()["embedding"]["available"] is False
def test_sdk_search_reports_fallback_on_embedding_error():
mgr = SDKKnowledgeManager()
err = Exception("Project does not have access to model text-embedding-3-small")
mgr._sdk_knowledge = _FakeSDK(search_error=err)
mgr._sdk_probed = True
mgr.store("PraisonAIUI is a YAML-driven UI generator.")
mgr.search("documentation generator")
meta = mgr.last_search_meta()
assert meta["mode"] == "fallback"
assert "text-embedding" in meta["error"].lower()
health = mgr.health()
assert health["embedding"]["available"] is False
assert health["status"] == "degraded"
assert health["warnings"]
def test_sdk_search_vector_mode_when_healthy():
class _OkSDK:
def search(self, query, limit=1, user_id=None):
if query == "__probe__":
return []
return [{"id": "1", "memory": "hit", "score": 0.9}]
mgr = SDKKnowledgeManager()
mgr._sdk_knowledge = _OkSDK()
mgr._sdk_probed = True
results = mgr.search("anything")
assert results
assert mgr.last_search_meta()["mode"] == "vector"
def test_probe_flags_embedding_unavailable():
mgr = SDKKnowledgeManager()
class _Kn:
def search(self, query, limit=1):
raise Exception("model_not_found: no access")
with patch("praisonaiagents.knowledge.Knowledge", return_value=_Kn(), create=True):
mgr._get_sdk_knowledge()
assert mgr._embedding_unavailable is True
@pytest.fixture
def client():
saved = dict(_features)
saved_mgr = knowledge_mod._knowledge_manager
_features.clear()
register_feature(KnowledgeFeature())
register_feature(OpenAIAPIFeature())
yield TestClient(create_app())
_features.clear()
_features.update(saved)
knowledge_mod._knowledge_manager = saved_mgr
def test_knowledge_search_reports_fallback(client):
mgr = SDKKnowledgeManager()
err = Exception("Project does not have access to model text-embedding-3-small")
mgr._sdk_knowledge = _FakeSDK(search_error=err)
mgr._sdk_probed = True
mgr.store("PraisonAIUI is a YAML-driven documentation site generator.")
knowledge_mod._knowledge_manager = mgr
r = client.post("/api/knowledge/search", json={"query": "documentation generator YAML"})
assert r.status_code == 200
body = r.json()
assert body["search_mode"] == "fallback"
assert body["warning"]
assert "embedding" in body["warning"].lower()
def test_knowledge_status_reports_embedding(client):
mgr = SDKKnowledgeManager()
mgr._sdk_knowledge = _FakeSDK()
mgr._sdk_probed = True
mgr._embedding_unavailable = True
mgr._embedding_error = "no access"
knowledge_mod._knowledge_manager = mgr
r = client.get("/api/knowledge/status")
assert r.status_code == 200
body = r.json()
assert body["embedding"]["available"] is False
assert body["warnings"]
def test_embeddings_endpoint_fallback_models(client):
call_models: list[str] = []
async def _aembed(input, model):
call_models.append(model)
if model == "text-embedding-3-small":
raise Exception("Project does not have access to model text-embedding-3-small")
return {"embedding": [0.1, 0.2], "usage": {"prompt_tokens": 1, "total_tokens": 1}}
fake_caps = type("Caps", (), {"aembed": staticmethod(AsyncMock(side_effect=_aembed))})
with patch(
"praisonaiui.features.openai_api._get_capabilities", return_value=fake_caps
):
r = client.post(
"/v1/embeddings",
json={"input": "test", "model": "text-embedding-3-small"},
)
assert r.status_code == 200
body = r.json()
assert body["model"] == "text-embedding-ada-002"
assert call_models[0] == "text-embedding-3-small"
def test_embeddings_endpoint_all_fail_returns_503(client):
async def _aembed(input, model):
raise Exception("model_not_found: no access to any embedding model")
fake_caps = type("Caps", (), {"aembed": staticmethod(AsyncMock(side_effect=_aembed))})
with patch(
"praisonaiui.features.openai_api._get_capabilities", return_value=fake_caps
):
r = client.post("/v1/embeddings", json={"input": "test"})
assert r.status_code == 503
assert r.json()["error"]["type"] == "model_not_found"
+12 -1
View File
@@ -21,12 +21,23 @@ def client():
_agents.clear()
_callbacks.clear()
import praisonaiui.server as _srv
from praisonaiui.features import _features
_srv._DEEP_HEALTH_CACHE = {}
_srv._DEEP_HEALTH_CACHE_TIME = 0.0
# Snapshot and clear the global feature registry so a feature leaked by an
# earlier test (e.g. one that crashed before its cleanup) cannot slow down
# /health and flake the deep-health timing/cache assertions under the full
# suite. See issue #256.
_features_snapshot = dict(_features)
_features.clear()
set_datastore(MemoryDataStore())
app = create_app()
return TestClient(app)
try:
yield TestClient(app)
finally:
_features.clear()
_features.update(_features_snapshot)
class TestHealthEndpoint:
+2 -2
View File
@@ -22,7 +22,7 @@ CHAT_JS = FRONTEND / "views" / "chat.js"
class TestDashboardSessionSearch:
def _src(self) -> str:
return DASHBOARD_JS.read_text()
return DASHBOARD_JS.read_text(encoding="utf-8")
def test_ctrl_k_keydown_handler_present(self):
# The Ctrl+K binding is owned by the command palette, which exposes a
@@ -107,7 +107,7 @@ class TestDashboardSessionSearch:
class TestChatViewSessionSelectListener:
def _src(self) -> str:
return CHAT_JS.read_text()
return CHAT_JS.read_text(encoding="utf-8")
def test_chat_listens_for_session_select(self):
src = self._src()
+12 -12
View File
@@ -48,41 +48,41 @@ class TestI18nKeys:
class TestHelpersJs:
def test_helpers_export_functions(self):
src = HELPERS_JS.read_text()
src = HELPERS_JS.read_text(encoding="utf-8")
assert "export function diffLines(" in src
assert "export function renderMarkdownPreview(" in src
assert "export function isSkillApproval(" in src
def test_diff_normalises_crlf(self):
src = HELPERS_JS.read_text()
src = HELPERS_JS.read_text(encoding="utf-8")
assert "replace(/\\r\\n?/g, '\\n')" in src
def test_preview_escapes_first(self):
src = HELPERS_JS.read_text()
src = HELPERS_JS.read_text(encoding="utf-8")
assert "esc(s)" in src
class TestSkillsJs:
def test_exports_render_and_cleanup(self):
src = SKILLS_JS.read_text()
src = SKILLS_JS.read_text(encoding="utf-8")
assert "export async function render" in src
assert "export function cleanup" in src
def test_tab_shell_present(self):
src = SKILLS_JS.read_text()
src = SKILLS_JS.read_text(encoding="utf-8")
assert "class=\"ss-tab\"" in src
assert "tabBtn('pending'" in src
assert "tabBtn('installed'" in src
assert "tabBtn('history'" in src
def test_url_state_and_deep_link(self):
src = SKILLS_JS.read_text()
src = SKILLS_JS.read_text(encoding="utf-8")
assert "approval_id" in src
assert "currentApprovalId(" in src
assert "openDiff(" in src
def test_uses_existing_apis(self):
src = SKILLS_JS.read_text()
src = SKILLS_JS.read_text(encoding="utf-8")
assert "/api/approvals/pending" in src
assert "/api/approvals/history" in src
assert "/api/skills" in src
@@ -90,30 +90,30 @@ class TestSkillsJs:
assert "/api/approvals/${id}/${verb}" in src
def test_imports_skill_helpers(self):
src = SKILLS_JS.read_text()
src = SKILLS_JS.read_text(encoding="utf-8")
assert "isSkillApproval" in src
assert "diffLines" in src
assert "renderMarkdownPreview" in src
def test_no_browser_eval(self):
src = SKILLS_JS.read_text()
src = SKILLS_JS.read_text(encoding="utf-8")
assert "eval(" not in src
def test_proposed_content_escaped_in_diff(self):
src = SKILLS_JS.read_text()
src = SKILLS_JS.read_text(encoding="utf-8")
# Diff rows must escape line text; no raw interpolation of r.text.
assert "esc(r.text)" in src
assert "${r.text}" not in src
def test_empty_state_and_no_existing_labels(self):
src = SKILLS_JS.read_text()
src = SKILLS_JS.read_text(encoding="utf-8")
assert "No pending skill writes" in src
assert "No existing skill" in src
class TestApprovalsCrossLink:
def test_skill_row_links_to_studio(self):
src = APPROVALS_JS.read_text()
src = APPROVALS_JS.read_text(encoding="utf-8")
assert "isSkillApproval" in src
assert "/skills?tab=pending&approval_id=" in src
+6 -4
View File
@@ -89,13 +89,15 @@ async def test_missing_dependencies_error(monkeypatch):
@pytest.mark.asyncio
async def test_default_sqlite_url():
async def test_default_sqlite_url(tmp_path):
"""Default should create ~/.praisonaiui/aiui.db"""
from praisonaiui.datastore import SQLAlchemyDataStore
with patch.dict(os.environ, {}, clear=True):
home = str(tmp_path)
env = {"HOME": home, "USERPROFILE": home}
with patch.dict(os.environ, env, clear=True):
store = SQLAlchemyDataStore()
expected = str(Path.home() / ".praisonaiui" / "aiui.db")
expected = (Path(home) / ".praisonaiui" / "aiui.db").as_posix()
assert expected in store._database_url
assert store._database_url.startswith("sqlite+aiosqlite:///")
@@ -108,7 +110,7 @@ async def test_respects_aiui_data_dir_env():
with tempfile.TemporaryDirectory() as tmpdir:
with patch.dict(os.environ, {"AIUI_DATA_DIR": tmpdir}):
store = SQLAlchemyDataStore()
expected = str(Path(tmpdir) / "aiui.db")
expected = (Path(tmpdir) / "aiui.db").as_posix()
assert expected in store._database_url
+67
View File
@@ -0,0 +1,67 @@
"""Regression tests for opt-in token auth activation (#238, #251).
The mere presence of GATEWAY_AUTH_TOKEN / AIUI_URL_TOKEN in the environment
(e.g. leaking into a developer shell after a gateway run) must not force 401
on standalone create_app() + TestClient. Token auth activates only when
explicitly opted in via AIUI_REQUIRE_TOKEN, YAML auth.requireToken, or
require_auth=True.
"""
import pytest
from starlette.testclient import TestClient
from praisonaiui import server
from praisonaiui.server import create_app
@pytest.fixture(autouse=True)
def _reset_state():
server.reset_state()
yield
server.reset_state()
def test_env_token_alone_does_not_force_401(monkeypatch):
monkeypatch.setenv("GATEWAY_AUTH_TOKEN", "test-secret-token")
monkeypatch.delenv("AIUI_REQUIRE_TOKEN", raising=False)
client = TestClient(create_app())
assert client.get("/api/agents").status_code == 200
def test_aiui_url_token_alone_does_not_force_401(monkeypatch):
monkeypatch.setenv("AIUI_URL_TOKEN", "test-secret-token")
monkeypatch.delenv("AIUI_REQUIRE_TOKEN", raising=False)
client = TestClient(create_app())
assert client.get("/api/agents").status_code == 200
def test_require_token_env_opt_in_enforces_401(monkeypatch):
monkeypatch.setenv("GATEWAY_AUTH_TOKEN", "test-secret-token")
monkeypatch.setenv("AIUI_REQUIRE_TOKEN", "1")
client = TestClient(create_app())
assert client.get("/api/agents").status_code == 401
def test_require_token_env_opt_in_allows_bearer(monkeypatch):
monkeypatch.setenv("GATEWAY_AUTH_TOKEN", "test-secret-token")
monkeypatch.setenv("AIUI_REQUIRE_TOKEN", "1")
client = TestClient(create_app())
resp = client.get(
"/api/agents",
headers={"Authorization": "Bearer test-secret-token"},
)
assert resp.status_code == 200
def test_config_require_token_opt_in_enforces_401(monkeypatch):
monkeypatch.setenv("AIUI_URL_TOKEN", "test-secret-token")
monkeypatch.delenv("AIUI_REQUIRE_TOKEN", raising=False)
client = TestClient(create_app(config={"auth": {"requireToken": True}}))
assert client.get("/api/agents").status_code == 401
def test_require_auth_opt_in_enforces_401(monkeypatch):
monkeypatch.setenv("AIUI_URL_TOKEN", "test-secret-token")
monkeypatch.delenv("AIUI_REQUIRE_TOKEN", raising=False)
client = TestClient(create_app(require_auth=True))
assert client.get("/api/agents").status_code == 401
+26 -11
View File
@@ -151,17 +151,32 @@ class TestCompetitorScrub:
"""The peer framework's name must never reappear in the source tree."""
def test_zero_matches_in_source(self):
import subprocess
r = subprocess.run(
["grep", "-rn", "-i", "chainlit",
"src/praisonaiui", "docs", "examples", "tests",
"--include=*.py", "--include=*.md", "--include=*.yaml",
"--include=*.yml", "--include=*.ts", "--include=*.tsx"],
capture_output=True, text=True, cwd=_repo_root(),
)
hits = [line for line in r.stdout.splitlines()
if ".pyc" not in line
and "tests/unit/test_unified_naming" not in line]
import pathlib
root = pathlib.Path(_repo_root())
needle = "chainlit"
suffixes = {".py", ".md", ".yaml", ".yml", ".ts", ".tsx"}
search_dirs = ["src/praisonaiui", "docs", "examples", "tests"]
hits = []
for rel in search_dirs:
base = root / rel
if not base.exists():
continue
for path in base.rglob("*"):
if not path.is_file() or path.suffix not in suffixes:
continue
rel_path = path.relative_to(root).as_posix()
if "test_unified_naming" in rel_path:
continue
try:
text = path.read_text(encoding="utf-8", errors="ignore")
except OSError:
continue
for lineno, line in enumerate(text.splitlines(), 1):
if needle in line.lower():
hits.append(f"{rel_path}:{lineno}:{line}")
assert not hits, f"Competitor name reappeared: {hits}"
Generated
+1 -1
View File
@@ -207,7 +207,7 @@ wheels = [
[[package]]
name = "aiui"
version = "0.3.134"
version = "0.3.138"
source = { editable = "." }
dependencies = [
{ name = "pydantic" },