mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 05:56:17 -10:00
Compare commits
31
Commits
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);
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
));
|
||||
|
||||
@@ -113,7 +113,7 @@ function conflictRebaseQuiescent(comments, headPushedAt) {
|
||||
);
|
||||
if (!rebaseDone) return false;
|
||||
|
||||
return finalClaudeCompletedOnSha(comments, headPushedAt);
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasRecentConflictComment(comments, headPushedAt = null) {
|
||||
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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 |
|
||||
@@ -44,15 +44,18 @@ import sys
|
||||
# ── Import praisonaiui ──────────────────────────────────────
|
||||
import praisonaiui as aiui
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from _shared.console import icon, safe_print
|
||||
|
||||
# ── Check gateway availability ──────────────────────────────
|
||||
try:
|
||||
from praisonaiui.integration import AIUIGateway
|
||||
from praisonaiagents import Agent
|
||||
GATEWAY_OK = True
|
||||
except ImportError as e:
|
||||
print(f"⚠️ Gateway dependencies missing: {e}")
|
||||
print(" Install with: pip install praisonai praisonaiagents")
|
||||
print(" Falling back to standalone mode.")
|
||||
safe_print(f"{icon('⚠️', '[WARN]')} Gateway dependencies missing: {e}")
|
||||
safe_print(" Install with: pip install praisonai praisonaiagents")
|
||||
safe_print(" Falling back to standalone mode.")
|
||||
GATEWAY_OK = False
|
||||
|
||||
# ── Set dashboard style ─────────────────────────────────────
|
||||
@@ -149,15 +152,15 @@ def _register_agents_in_dashboard():
|
||||
"model": agent_def["model"],
|
||||
"icon": agent_def["icon"],
|
||||
})
|
||||
print(f" ✓ Dashboard: {agent_def['icon']} {agent_def['name']}")
|
||||
safe_print(f" {icon('✓', '[OK]')} Dashboard: {agent_def['name']}")
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────
|
||||
async def main():
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
if GATEWAY_OK:
|
||||
print("🚀 Starting with Gateway mode (full execution + streaming)")
|
||||
print()
|
||||
safe_print(f"{icon('🚀', '[START]')} Starting with Gateway mode (full execution + streaming)")
|
||||
safe_print()
|
||||
|
||||
# Create the gateway
|
||||
gateway = AIUIGateway(
|
||||
@@ -174,22 +177,22 @@ async def main():
|
||||
self_reflect=False,
|
||||
)
|
||||
gateway.register_agent(agent, agent_id=agent_def["agent_id"])
|
||||
print(f" ✓ Gateway: {agent_def['icon']} {agent_def['name']} ({agent_def['model']})")
|
||||
safe_print(f" {icon('✓', '[OK]')} Gateway: {agent_def['name']} ({agent_def['model']})")
|
||||
|
||||
# ── Register agents in dashboard CRUD storage ───────
|
||||
_register_agents_in_dashboard()
|
||||
|
||||
print()
|
||||
print(f" Dashboard: http://localhost:8083")
|
||||
print(f" WebSocket: ws://localhost:8083/ws")
|
||||
print(f" Inspector: http://localhost:8083 → 🔍 Feature Inspector")
|
||||
print()
|
||||
safe_print()
|
||||
safe_print(" Dashboard: http://localhost:8083")
|
||||
safe_print(" WebSocket: ws://localhost:8083/ws")
|
||||
safe_print(f" Inspector: http://localhost:8083 {icon('→', '->')} {icon('🔍', '')} Feature Inspector")
|
||||
safe_print()
|
||||
|
||||
# Start the gateway — this runs the full server
|
||||
await gateway.start()
|
||||
else:
|
||||
# Fallback to standalone mode (same as example 15)
|
||||
print("📦 Starting in standalone mode (CRUD only, no agent execution)")
|
||||
safe_print(f"{icon('📦', '[INFO]')} Starting in standalone mode (CRUD only, no agent execution)")
|
||||
from praisonaiui.server import create_app
|
||||
import uvicorn
|
||||
|
||||
|
||||
@@ -26,15 +26,18 @@ import sys
|
||||
|
||||
import praisonaiui as aiui
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from _shared.console import icon, safe_print
|
||||
|
||||
# ── Check gateway availability ──────────────────────────────
|
||||
try:
|
||||
from praisonaiui.integration import AIUIGateway
|
||||
from praisonaiagents import Agent
|
||||
GATEWAY_OK = True
|
||||
except ImportError as e:
|
||||
print(f"⚠️ Gateway dependencies not found: {e}")
|
||||
print(" Install with: pip install praisonai praisonaiagents")
|
||||
print(" Falling back to standalone mode (REST APIs only).\n")
|
||||
safe_print(f"{icon('⚠️', '[WARN]')} Gateway dependencies not found: {e}")
|
||||
safe_print(" Install with: pip install praisonai praisonaiagents")
|
||||
safe_print(" Falling back to standalone mode (REST APIs only).\n")
|
||||
GATEWAY_OK = False
|
||||
|
||||
aiui.set_style("dashboard")
|
||||
@@ -115,7 +118,7 @@ def _register_agents_in_dashboard():
|
||||
"model": agent_def["model"],
|
||||
"icon": agent_def["icon"],
|
||||
})
|
||||
print(f" ✓ Agent: {agent_def['icon']} {agent_def['name']}")
|
||||
safe_print(f" {icon('✓', '[OK]')} Agent: {agent_def['name']}")
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────
|
||||
@@ -125,8 +128,8 @@ async def main():
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
|
||||
if GATEWAY_OK:
|
||||
print("🚀 Starting with Gateway mode (full execution + streaming)")
|
||||
print()
|
||||
safe_print(f"{icon('🚀', '[START]')} Starting with Gateway mode (full execution + streaming)")
|
||||
safe_print()
|
||||
|
||||
gateway = AIUIGateway(host=host, port=port)
|
||||
|
||||
@@ -139,32 +142,32 @@ async def main():
|
||||
reflection=False,
|
||||
)
|
||||
gateway.register_agent(agent, agent_id=agent_def["agent_id"])
|
||||
print(f" ✓ Gateway: {agent_def['icon']} {agent_def['name']} ({agent_def['model']})")
|
||||
safe_print(f" {icon('✓', '[OK]')} Gateway: {agent_def['name']} ({agent_def['model']})")
|
||||
|
||||
# Also register in dashboard CRUD
|
||||
_register_agents_in_dashboard()
|
||||
|
||||
print()
|
||||
print(f" Dashboard: http://localhost:{port}")
|
||||
print(f" Explorer: http://localhost:{port} → 🔬 Feature Explorer")
|
||||
print(f" WebSocket: ws://localhost:{port}/ws")
|
||||
print()
|
||||
safe_print()
|
||||
safe_print(f" Dashboard: http://localhost:{port}")
|
||||
safe_print(f" Explorer: http://localhost:{port} {icon('→', '->')} {icon('🔬', '')} Feature Explorer")
|
||||
safe_print(f" WebSocket: ws://localhost:{port}/ws")
|
||||
safe_print()
|
||||
|
||||
await gateway.start()
|
||||
else:
|
||||
# Standalone: REST APIs only, no agent execution
|
||||
print("📦 Starting in standalone mode (REST APIs only, no agent execution)")
|
||||
safe_print(f"{icon('📦', '[INFO]')} Starting in standalone mode (REST APIs only, no agent execution)")
|
||||
from praisonaiui.server import create_app
|
||||
import uvicorn
|
||||
|
||||
_register_agents_in_dashboard()
|
||||
|
||||
app = create_app()
|
||||
print()
|
||||
print(f" Dashboard: http://localhost:{port}")
|
||||
print(f" Explorer: http://localhost:{port} → 🔬 Feature Explorer")
|
||||
print(f" Note: POST endpoints may fail without gateway")
|
||||
print()
|
||||
safe_print()
|
||||
safe_print(f" Dashboard: http://localhost:{port}")
|
||||
safe_print(f" Explorer: http://localhost:{port} {icon('→', '->')} {icon('🔬', '')} Feature Explorer")
|
||||
safe_print(" Note: POST endpoints may fail without gateway")
|
||||
safe_print()
|
||||
|
||||
config = uvicorn.Config(app, host=host, port=port, log_level="info")
|
||||
server = uvicorn.Server(config)
|
||||
|
||||
@@ -25,9 +25,13 @@ Usage:
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
import praisonaiui as aiui
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from _shared.console import icon, safe_print
|
||||
|
||||
# ── Check gateway availability ──────────────────────────────
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
@@ -35,9 +39,9 @@ try:
|
||||
from praisonaiui.integration import AIUIGateway
|
||||
GATEWAY_OK = True
|
||||
except ImportError as e:
|
||||
print(f"⚠️ Gateway dependencies not found: {e}")
|
||||
print(" Install with: pip install praisonai praisonaiagents")
|
||||
print(" Falling back to standalone mode (REST APIs only).\n")
|
||||
safe_print(f"{icon('⚠️', '[WARN]')} Gateway dependencies not found: {e}")
|
||||
safe_print(" Install with: pip install praisonai praisonaiagents")
|
||||
safe_print(" Falling back to standalone mode (REST APIs only).\n")
|
||||
GATEWAY_OK = False
|
||||
|
||||
aiui.set_style("dashboard")
|
||||
@@ -127,7 +131,7 @@ def _register_agents_in_dashboard():
|
||||
"model": agent_def["model"],
|
||||
"icon": agent_def["icon"],
|
||||
})
|
||||
print(f" ✓ Agent: {agent_def['icon']} {agent_def['name']}")
|
||||
safe_print(f" {icon('✓', '[OK]')} Agent: {agent_def['name']}")
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────
|
||||
@@ -137,8 +141,8 @@ async def main():
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
|
||||
if GATEWAY_OK:
|
||||
print("🚀 Starting with Gateway mode (full execution + streaming)")
|
||||
print()
|
||||
safe_print(f"{icon('🚀', '[START]')} Starting with Gateway mode (full execution + streaming)")
|
||||
safe_print()
|
||||
|
||||
gateway = AIUIGateway(host=host, port=port)
|
||||
|
||||
@@ -151,21 +155,21 @@ async def main():
|
||||
reflection=False,
|
||||
)
|
||||
gateway.register_agent(agent, agent_id=agent_def["agent_id"])
|
||||
print(f" ✓ Gateway: {agent_def['icon']} {agent_def['name']} ({agent_def['model']})")
|
||||
safe_print(f" {icon('✓', '[OK]')} Gateway: {agent_def['name']} ({agent_def['model']})")
|
||||
|
||||
# Also register in dashboard CRUD
|
||||
_register_agents_in_dashboard()
|
||||
|
||||
print()
|
||||
print(f" Dashboard: http://localhost:{port}")
|
||||
print(f" Explorer: http://localhost:{port} → 🔬 Feature Explorer")
|
||||
print(f" WebSocket: ws://localhost:{port}/ws")
|
||||
print()
|
||||
safe_print()
|
||||
safe_print(f" Dashboard: http://localhost:{port}")
|
||||
safe_print(f" Explorer: http://localhost:{port} {icon('→', '->')} {icon('🔬', '')} Feature Explorer")
|
||||
safe_print(f" WebSocket: ws://localhost:{port}/ws")
|
||||
safe_print()
|
||||
|
||||
await gateway.start()
|
||||
else:
|
||||
# Standalone: REST APIs only, no agent execution
|
||||
print("📦 Starting in standalone mode (REST APIs only, no agent execution)")
|
||||
safe_print(f"{icon('📦', '[INFO]')} Starting in standalone mode (REST APIs only, no agent execution)")
|
||||
import uvicorn
|
||||
|
||||
from praisonaiui.server import create_app
|
||||
@@ -173,11 +177,11 @@ async def main():
|
||||
_register_agents_in_dashboard()
|
||||
|
||||
app = create_app()
|
||||
print()
|
||||
print(f" Dashboard: http://localhost:{port}")
|
||||
print(f" Explorer: http://localhost:{port} → 🔬 Feature Explorer")
|
||||
print(" Note: POST endpoints may fail without gateway")
|
||||
print()
|
||||
safe_print()
|
||||
safe_print(f" Dashboard: http://localhost:{port}")
|
||||
safe_print(f" Explorer: http://localhost:{port} {icon('→', '->')} {icon('🔬', '')} Feature Explorer")
|
||||
safe_print(" Note: POST endpoints may fail without gateway")
|
||||
safe_print()
|
||||
|
||||
config = uvicorn.Config(app, host=host, port=port, log_level="info")
|
||||
server = uvicorn.Server(config)
|
||||
|
||||
@@ -32,6 +32,9 @@ import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from _shared.console import icon, safe_print
|
||||
|
||||
# ── Check prerequisites ─────────────────────────────────────────────
|
||||
|
||||
try:
|
||||
@@ -40,8 +43,8 @@ try:
|
||||
SDK_OK = True
|
||||
except ImportError:
|
||||
SDK_OK = False
|
||||
print("⚠️ Missing dependencies. Install with:")
|
||||
print(" pip install praisonai[email] praisonaiagents")
|
||||
safe_print(f"{icon('⚠️', '[WARN]')} Missing dependencies. Install with:")
|
||||
safe_print(" pip install praisonai[email] praisonaiagents")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -68,8 +71,8 @@ agent = Agent(
|
||||
async def run_simple():
|
||||
"""Start email bot with automatic env var resolution."""
|
||||
bot = Bot("email", agent=agent)
|
||||
print(f"📧 Starting email bot for: {os.getenv('EMAIL_ADDRESS', '(not set)')}")
|
||||
print(" Listening for new emails... (Ctrl+C to stop)")
|
||||
safe_print(f"{icon('📧', '[EMAIL]')} Starting email bot for: {os.getenv('EMAIL_ADDRESS', '(not set)')}")
|
||||
safe_print(" Listening for new emails... (Ctrl+C to stop)")
|
||||
await bot.start()
|
||||
|
||||
|
||||
@@ -96,9 +99,9 @@ async def run_with_handlers():
|
||||
@bot.on_message
|
||||
async def log_email(message):
|
||||
subject = message.metadata.get("subject", "(no subject)")
|
||||
print(f"📨 From: {message.sender.username}")
|
||||
print(f" Subject: {subject}")
|
||||
print(f" Preview: {message.text[:100]}...")
|
||||
safe_print(f"{icon('📨', '[MSG]')} From: {message.sender.username}")
|
||||
safe_print(f" Subject: {subject}")
|
||||
safe_print(f" Preview: {message.text[:100]}...")
|
||||
|
||||
# Custom command: emails with subject starting with "/status"
|
||||
@bot.on_command("status")
|
||||
@@ -118,11 +121,11 @@ async def run_with_handlers():
|
||||
reply_to=message.message_id,
|
||||
)
|
||||
|
||||
print(f"📧 Starting email bot with custom handlers")
|
||||
print(f" Email: {bot._email_address}")
|
||||
print(f" Poll interval: {config.polling_interval}s")
|
||||
print(f" Allowed senders: {'all' if not config.allowed_users else config.allowed_users}")
|
||||
print(" Listening... (Ctrl+C to stop)")
|
||||
safe_print(f"{icon('📧', '[EMAIL]')} Starting email bot with custom handlers")
|
||||
safe_print(f" Email: {bot._email_address}")
|
||||
safe_print(f" Poll interval: {config.polling_interval}s")
|
||||
safe_print(f" Allowed senders: {'all' if not config.allowed_users else config.allowed_users}")
|
||||
safe_print(" Listening... (Ctrl+C to stop)")
|
||||
await bot.start()
|
||||
|
||||
|
||||
@@ -131,17 +134,17 @@ async def run_with_handlers():
|
||||
async def run_probe():
|
||||
"""Test email server connectivity."""
|
||||
bot = Bot("email", agent=agent)
|
||||
print("🔍 Testing email server connectivity...")
|
||||
safe_print(f"{icon('🔍', '[PROBE]')} Testing email server connectivity...")
|
||||
result = await bot.probe()
|
||||
|
||||
print(f"\n Connection: {'✅ OK' if result.ok else '❌ FAILED'}")
|
||||
print(f" Platform: {result.platform}")
|
||||
safe_print(f"\n Connection: {icon('✅', '[OK]') + ' OK' if result.ok else icon('❌', '[FAIL]') + ' FAILED'}")
|
||||
safe_print(f" Platform: {result.platform}")
|
||||
if result.bot_username:
|
||||
print(f" Email: {result.bot_username}")
|
||||
safe_print(f" Email: {result.bot_username}")
|
||||
if result.elapsed_ms:
|
||||
print(f" Latency: {result.elapsed_ms:.0f}ms")
|
||||
safe_print(f" Latency: {result.elapsed_ms:.0f}ms")
|
||||
if result.error:
|
||||
print(f" Error: {result.error}")
|
||||
safe_print(f" Error: {result.error}")
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -42,7 +42,9 @@ import sys
|
||||
# ── Imports ─────────────────────────────────────────────────────────
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "src"))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from _shared.console import icon, safe_print
|
||||
from praisonaiui.server import create_app
|
||||
import uvicorn
|
||||
|
||||
@@ -91,11 +93,11 @@ def seed_email_channel():
|
||||
})
|
||||
if r.status_code in (200, 201):
|
||||
channel_id = r.json().get("id", "unknown")
|
||||
print(f" ✅ Email channel registered: {channel_id}")
|
||||
safe_print(f" {icon('✅', '[OK]')} Email channel registered: {channel_id}")
|
||||
else:
|
||||
print(f" ⚠️ Channel registration: {r.status_code} — {r.json()}")
|
||||
safe_print(f" {icon('⚠️', '[WARN]')} Channel registration: {r.status_code} - {r.json()}")
|
||||
else:
|
||||
print(" ✓ Email channel already exists")
|
||||
safe_print(f" {icon('✓', '[OK]')} Email channel already exists")
|
||||
|
||||
# Also seed other channels for comparison in dashboard
|
||||
for name, platform in [("Discord #general", "discord"), ("Telegram Support", "telegram")]:
|
||||
@@ -106,7 +108,7 @@ def seed_email_channel():
|
||||
"platform": platform,
|
||||
"config": {},
|
||||
})
|
||||
print(f" ✓ Seeded {platform} channel")
|
||||
safe_print(f" {icon('✓', '[OK]')} Seeded {platform} channel")
|
||||
|
||||
|
||||
# ── Verify email platform support ──────────────────────────────────
|
||||
@@ -122,8 +124,8 @@ def verify_email_support():
|
||||
if r.status_code == 200:
|
||||
platforms = r.json().get("platforms", [])
|
||||
email_supported = "email" in platforms
|
||||
print(f" Supported platforms: {platforms}")
|
||||
print(f" Email supported: {'✅ Yes' if email_supported else '❌ No — add email to SUPPORTED_PLATFORMS'}")
|
||||
safe_print(f" Supported platforms: {platforms}")
|
||||
safe_print(f" Email supported: {icon('✅', '[OK]') + ' Yes' if email_supported else icon('❌', '[X]') + ' No - add email to SUPPORTED_PLATFORMS'}")
|
||||
return email_supported
|
||||
return False
|
||||
|
||||
@@ -137,51 +139,51 @@ def test_channel_api():
|
||||
app = create_app()
|
||||
client = TestClient(app)
|
||||
|
||||
print("\n📋 Channel API Test")
|
||||
print("=" * 40)
|
||||
safe_print(f"\n{icon('📋', '[TEST]')} Channel API Test")
|
||||
safe_print("=" * 40)
|
||||
|
||||
# List channels
|
||||
r = client.get("/api/channels")
|
||||
channels = r.json().get("channels", [])
|
||||
print(f" GET /api/channels: {len(channels)} channels")
|
||||
safe_print(f" GET /api/channels: {len(channels)} channels")
|
||||
for ch in channels:
|
||||
icon = "📧" if ch.get("platform") == "email" else "💬"
|
||||
print(f" {icon} {ch.get('name', '?')} ({ch.get('platform', '?')})")
|
||||
ch_icon = icon("📧", "[email]") if ch.get("platform") == "email" else icon("💬", "[chat]")
|
||||
safe_print(f" {ch_icon} {ch.get('name', '?')} ({ch.get('platform', '?')})")
|
||||
|
||||
# Get email channel status
|
||||
email_ch = next((ch for ch in channels if ch.get("platform") == "email"), None)
|
||||
if email_ch:
|
||||
ch_id = email_ch.get("id")
|
||||
r = client.get(f"/api/channels/{ch_id}/status")
|
||||
print(f" GET /api/channels/{ch_id}/status: {r.json()}")
|
||||
safe_print(f" GET /api/channels/{ch_id}/status: {r.json()}")
|
||||
|
||||
print()
|
||||
safe_print()
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("📧 PraisonAIUI — Email Channel Integration")
|
||||
print("=" * 50)
|
||||
print()
|
||||
safe_print(f"{icon('📧', '[EMAIL]')} PraisonAIUI - Email Channel Integration")
|
||||
safe_print("=" * 50)
|
||||
safe_print()
|
||||
|
||||
if "--test" in sys.argv:
|
||||
# Test mode: verify APIs + seed data
|
||||
print("🔍 Verifying email platform support...")
|
||||
safe_print(f"{icon('🔍', '[VERIFY]')} Verifying email platform support...")
|
||||
verify_email_support()
|
||||
print()
|
||||
print("🌱 Seeding email channel...")
|
||||
safe_print()
|
||||
safe_print(f"{icon('🌱', '[SEED]')} Seeding email channel...")
|
||||
seed_email_channel()
|
||||
test_channel_api()
|
||||
else:
|
||||
# Run the dashboard
|
||||
print("🌱 Seeding channels...")
|
||||
safe_print(f"{icon('🌱', '[SEED]')} Seeding channels...")
|
||||
seed_email_channel()
|
||||
print()
|
||||
safe_print()
|
||||
|
||||
app = create_app()
|
||||
print(f"✅ Dashboard at http://localhost:8084")
|
||||
print(f" Channels: http://localhost:8084/api/channels")
|
||||
print(f" Platforms: http://localhost:8084/api/channels/platforms")
|
||||
safe_print(f"{icon('✅', '[OK]')} Dashboard at http://localhost:8084")
|
||||
safe_print(" Channels: http://localhost:8084/api/channels")
|
||||
safe_print(" Platforms: http://localhost:8084/api/channels/platforms")
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
uvicorn.run(app, host=host, port=8084, log_level="info")
|
||||
@@ -33,6 +33,9 @@ import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from _shared.console import icon, safe_print
|
||||
|
||||
# ── Check prerequisites ─────────────────────────────────────────────
|
||||
|
||||
try:
|
||||
@@ -41,8 +44,8 @@ try:
|
||||
SDK_OK = True
|
||||
except ImportError:
|
||||
SDK_OK = False
|
||||
print("⚠️ Missing dependencies. Install with:")
|
||||
print(" pip install praisonai agentmail praisonaiagents")
|
||||
safe_print(f"{icon('⚠️', '[WARN]')} Missing dependencies. Install with:")
|
||||
safe_print(" pip install praisonai agentmail praisonaiagents")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -69,9 +72,9 @@ agent = Agent(
|
||||
async def run_simple():
|
||||
"""Start agentmail bot with automatic env var resolution."""
|
||||
bot = Bot("agentmail", agent=agent)
|
||||
print(f"📩 Starting AgentMail bot")
|
||||
print(f" API key: {'✅ set' if os.getenv('AGENTMAIL_API_KEY') else '❌ not set'}")
|
||||
print(" Listening for new emails... (Ctrl+C to stop)")
|
||||
safe_print(f"{icon('📩', '[MAIL]')} Starting AgentMail bot")
|
||||
safe_print(f" API key: {icon('✅', '[OK]') + ' set' if os.getenv('AGENTMAIL_API_KEY') else icon('❌', '[X]') + ' not set'}")
|
||||
safe_print(" Listening for new emails... (Ctrl+C to stop)")
|
||||
await bot.start()
|
||||
|
||||
|
||||
@@ -99,9 +102,9 @@ async def run_with_handlers():
|
||||
@bot.on_message
|
||||
async def log_email(message):
|
||||
subject = message.metadata.get("subject", "(no subject)")
|
||||
print(f"📨 From: {message.sender.username}")
|
||||
print(f" Subject: {subject}")
|
||||
print(f" Preview: {message.text[:100]}...")
|
||||
safe_print(f"{icon('📨', '[MSG]')} From: {message.sender.username}")
|
||||
safe_print(f" Subject: {subject}")
|
||||
safe_print(f" Preview: {message.text[:100]}...")
|
||||
|
||||
# Custom command: emails with subject starting with "/status"
|
||||
@bot.on_command("status")
|
||||
@@ -121,10 +124,10 @@ async def run_with_handlers():
|
||||
reply_to=message.message_id,
|
||||
)
|
||||
|
||||
print(f"📩 Starting AgentMail bot with custom handlers")
|
||||
print(f" Email: {bot.email_address or '(will be assigned on start)'}")
|
||||
print(f" Poll interval: {config.polling_interval}s")
|
||||
print(" Listening... (Ctrl+C to stop)")
|
||||
safe_print(f"{icon('📩', '[MAIL]')} Starting AgentMail bot with custom handlers")
|
||||
safe_print(f" Email: {bot.email_address or '(will be assigned on start)'}")
|
||||
safe_print(f" Poll interval: {config.polling_interval}s")
|
||||
safe_print(" Listening... (Ctrl+C to stop)")
|
||||
await bot.start()
|
||||
|
||||
|
||||
@@ -136,30 +139,30 @@ async def run_inbox_demo():
|
||||
token=os.environ.get("AGENTMAIL_API_KEY", ""),
|
||||
)
|
||||
|
||||
print("📩 AgentMail Inbox Lifecycle Demo")
|
||||
print("=" * 50)
|
||||
safe_print(f"{icon('📩', '[MAIL]')} AgentMail Inbox Lifecycle Demo")
|
||||
safe_print("=" * 50)
|
||||
|
||||
# Create inbox
|
||||
print("\n1. Creating inbox...")
|
||||
safe_print("\n1. Creating inbox...")
|
||||
inbox = await bot.create_inbox(domain=os.getenv("AGENTMAIL_DOMAIN"))
|
||||
print(f" ✅ Created: {inbox.get('email_address', 'unknown')}")
|
||||
print(f" ID: {inbox.get('id', 'unknown')}")
|
||||
safe_print(f" {icon('✅', '[OK]')} Created: {inbox.get('email_address', 'unknown')}")
|
||||
safe_print(f" ID: {inbox.get('id', 'unknown')}")
|
||||
|
||||
# List inboxes
|
||||
print("\n2. Listing inboxes...")
|
||||
safe_print("\n2. Listing inboxes...")
|
||||
inboxes = await bot.list_inboxes()
|
||||
print(f" Found: {len(inboxes)} inbox(es)")
|
||||
safe_print(f" Found: {len(inboxes)} inbox(es)")
|
||||
for ib in inboxes:
|
||||
print(f" 📧 {ib.get('email_address', '?')} (id: {ib.get('id', '?')})")
|
||||
safe_print(f" {icon('📧', '[EMAIL]')} {ib.get('email_address', '?')} (id: {ib.get('id', '?')})")
|
||||
|
||||
# Delete inbox
|
||||
inbox_id = inbox.get("id", "")
|
||||
if inbox_id:
|
||||
print(f"\n3. Deleting inbox {inbox_id}...")
|
||||
safe_print(f"\n3. Deleting inbox {inbox_id}...")
|
||||
deleted = await bot.delete_inbox(inbox_id)
|
||||
print(f" {'✅ Deleted' if deleted else '❌ Failed to delete'}")
|
||||
safe_print(f" {icon('✅', '[OK]') + ' Deleted' if deleted else icon('❌', '[X]') + ' Failed to delete'}")
|
||||
|
||||
print("\n✅ Inbox lifecycle complete")
|
||||
safe_print(f"\n{icon('✅', '[OK]')} Inbox lifecycle complete")
|
||||
|
||||
|
||||
# ── Probe: Test connectivity ────────────────────────────────────────
|
||||
@@ -167,17 +170,17 @@ async def run_inbox_demo():
|
||||
async def run_probe():
|
||||
"""Test AgentMail API connectivity."""
|
||||
bot = Bot("agentmail", agent=agent)
|
||||
print("🔍 Testing AgentMail API connectivity...")
|
||||
safe_print(f"{icon('🔍', '[PROBE]')} Testing AgentMail API connectivity...")
|
||||
result = await bot.probe()
|
||||
|
||||
print(f"\n Connection: {'✅ OK' if result.ok else '❌ FAILED'}")
|
||||
print(f" Platform: {result.platform}")
|
||||
safe_print(f"\n Connection: {icon('✅', '[OK]') + ' OK' if result.ok else icon('❌', '[FAIL]') + ' FAILED'}")
|
||||
safe_print(f" Platform: {result.platform}")
|
||||
if result.bot_username:
|
||||
print(f" Email: {result.bot_username}")
|
||||
safe_print(f" Email: {result.bot_username}")
|
||||
if result.elapsed_ms:
|
||||
print(f" Latency: {result.elapsed_ms:.0f}ms")
|
||||
safe_print(f" Latency: {result.elapsed_ms:.0f}ms")
|
||||
if result.error:
|
||||
print(f" Error: {result.error}")
|
||||
safe_print(f" Error: {result.error}")
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -40,7 +40,9 @@ import sys
|
||||
# ── Imports ─────────────────────────────────────────────────────────
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "src"))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from _shared.console import icon, safe_print
|
||||
from praisonaiui.server import create_app
|
||||
import uvicorn
|
||||
|
||||
@@ -88,11 +90,11 @@ def seed_agentmail_channel():
|
||||
})
|
||||
if r.status_code in (200, 201):
|
||||
channel_id = r.json().get("id", "unknown")
|
||||
print(f" ✅ AgentMail channel registered: {channel_id}")
|
||||
safe_print(f" {icon('✅', '[OK]')} AgentMail channel registered: {channel_id}")
|
||||
else:
|
||||
print(f" ⚠️ Channel registration: {r.status_code} — {r.json()}")
|
||||
safe_print(f" {icon('⚠️', '[WARN]')} Channel registration: {r.status_code} - {r.json()}")
|
||||
else:
|
||||
print(" ✓ AgentMail channel already exists")
|
||||
safe_print(f" {icon('✓', '[OK]')} AgentMail channel already exists")
|
||||
|
||||
# Also seed email + telegram for comparison in dashboard
|
||||
for name, platform in [("Support Email", "email"), ("Telegram Support", "telegram")]:
|
||||
@@ -103,7 +105,7 @@ def seed_agentmail_channel():
|
||||
"platform": platform,
|
||||
"config": {},
|
||||
})
|
||||
print(f" ✓ Seeded {platform} channel")
|
||||
safe_print(f" {icon('✓', '[OK]')} Seeded {platform} channel")
|
||||
|
||||
|
||||
# ── Verify agentmail platform support ──────────────────────────────
|
||||
@@ -120,9 +122,9 @@ def verify_agentmail_support():
|
||||
platforms = r.json().get("platforms", [])
|
||||
am_supported = "agentmail" in platforms
|
||||
email_supported = "email" in platforms
|
||||
print(f" Supported platforms: {platforms}")
|
||||
print(f" email supported: {'✅ Yes' if email_supported else '❌ No'}")
|
||||
print(f" agentmail supported: {'✅ Yes' if am_supported else '❌ No'}")
|
||||
safe_print(f" Supported platforms: {platforms}")
|
||||
safe_print(f" email supported: {icon('✅', '[OK]') + ' Yes' if email_supported else icon('❌', '[X]') + ' No'}")
|
||||
safe_print(f" agentmail supported: {icon('✅', '[OK]') + ' Yes' if am_supported else icon('❌', '[X]') + ' No'}")
|
||||
return am_supported
|
||||
return False
|
||||
|
||||
@@ -136,51 +138,51 @@ def test_channel_api():
|
||||
app = create_app()
|
||||
client = TestClient(app)
|
||||
|
||||
print("\n📋 Channel API Test")
|
||||
print("=" * 40)
|
||||
safe_print(f"\n{icon('📋', '[TEST]')} Channel API Test")
|
||||
safe_print("=" * 40)
|
||||
|
||||
# List channels
|
||||
r = client.get("/api/channels")
|
||||
channels = r.json().get("channels", [])
|
||||
print(f" GET /api/channels: {len(channels)} channels")
|
||||
safe_print(f" GET /api/channels: {len(channels)} channels")
|
||||
for ch in channels:
|
||||
icon = {"email": "📧", "agentmail": "📩"}.get(ch.get("platform", ""), "💬")
|
||||
print(f" {icon} {ch.get('name', '?')} ({ch.get('platform', '?')})")
|
||||
ch_icon = {"email": icon("📧", "[email]"), "agentmail": icon("📩", "[mail]")}.get(ch.get("platform", ""), icon("💬", "[chat]"))
|
||||
safe_print(f" {ch_icon} {ch.get('name', '?')} ({ch.get('platform', '?')})")
|
||||
|
||||
# Get agentmail channel status
|
||||
am_ch = next((ch for ch in channels if ch.get("platform") == "agentmail"), None)
|
||||
if am_ch:
|
||||
ch_id = am_ch.get("id")
|
||||
r = client.get(f"/api/channels/{ch_id}/status")
|
||||
print(f" GET /api/channels/{ch_id}/status: {r.json()}")
|
||||
safe_print(f" GET /api/channels/{ch_id}/status: {r.json()}")
|
||||
|
||||
print()
|
||||
safe_print()
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("📩 PraisonAIUI — AgentMail Channel Integration")
|
||||
print("=" * 50)
|
||||
print()
|
||||
safe_print(f"{icon('📩', '[MAIL]')} PraisonAIUI - AgentMail Channel Integration")
|
||||
safe_print("=" * 50)
|
||||
safe_print()
|
||||
|
||||
if "--test" in sys.argv:
|
||||
# Test mode: verify APIs + seed data
|
||||
print("🔍 Verifying agentmail platform support...")
|
||||
safe_print(f"{icon('🔍', '[VERIFY]')} Verifying agentmail platform support...")
|
||||
verify_agentmail_support()
|
||||
print()
|
||||
print("🌱 Seeding agentmail channel...")
|
||||
safe_print()
|
||||
safe_print(f"{icon('🌱', '[SEED]')} Seeding agentmail channel...")
|
||||
seed_agentmail_channel()
|
||||
test_channel_api()
|
||||
else:
|
||||
# Run the dashboard
|
||||
print("🌱 Seeding channels...")
|
||||
safe_print(f"{icon('🌱', '[SEED]')} Seeding channels...")
|
||||
seed_agentmail_channel()
|
||||
print()
|
||||
safe_print()
|
||||
|
||||
app = create_app()
|
||||
print(f"✅ Dashboard at http://localhost:8084")
|
||||
print(f" Channels: http://localhost:8084/api/channels")
|
||||
print(f" Platforms: http://localhost:8084/api/channels/platforms")
|
||||
safe_print(f"{icon('✅', '[OK]')} Dashboard at http://localhost:8084")
|
||||
safe_print(" Channels: http://localhost:8084/api/channels")
|
||||
safe_print(" Platforms: http://localhost:8084/api/channels/platforms")
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
uvicorn.run(app, host=host, port=8084, log_level="info")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Cross-platform console output for examples.
|
||||
|
||||
Legacy Windows consoles default stdout to a non-UTF code page (cp1252/cp437)
|
||||
that cannot encode emoji such as U+1F680. Calling ``print`` with those
|
||||
characters raises ``UnicodeEncodeError`` and crashes the example before the
|
||||
server starts. These helpers mirror the ``_supports_unicode``/``_icon`` pattern
|
||||
in ``praisonaiui.cli`` so examples degrade to ASCII instead of crashing while
|
||||
preserving emoji on UTF-aware terminals.
|
||||
|
||||
Usage:
|
||||
from _shared.console import safe_print, icon
|
||||
|
||||
safe_print(f"{icon('🚀', '[START]')} Starting gateway...")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
def supports_unicode() -> bool:
|
||||
try:
|
||||
encoding = sys.stdout.encoding or ""
|
||||
except Exception:
|
||||
return False
|
||||
return "utf" in encoding.lower()
|
||||
|
||||
|
||||
def icon(symbol: str, fallback: str) -> str:
|
||||
return symbol if supports_unicode() else fallback
|
||||
|
||||
|
||||
def safe_print(*args: Any, **kwargs: Any) -> None:
|
||||
sep = kwargs.pop("sep", " ")
|
||||
text = sep.join(str(a) for a in args)
|
||||
try:
|
||||
print(text, **kwargs)
|
||||
except UnicodeEncodeError:
|
||||
print(text.encode("ascii", errors="replace").decode("ascii"), **kwargs)
|
||||
+1
-1
@@ -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,3 +1,3 @@
|
||||
"""Version information."""
|
||||
|
||||
__version__ = "0.3.134"
|
||||
__version__ = "0.3.138"
|
||||
@@ -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."""
|
||||
|
||||
@@ -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", []),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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() :
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, (
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user