mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 05:56:17 -10:00
Compare commits
22
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,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "aiui"
|
||||
version = "0.3.135"
|
||||
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.135"
|
||||
__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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -116,7 +119,7 @@ class TestBuildCommand:
|
||||
|
||||
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,
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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