mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 05:56:17 -10:00
Merge pull request #216 from MervinPraison/claude/issue-208-20260714-i18n-memory
feat: wire i18n strings and per-turn memory search into context ring UI
This commit is contained in:
commit
d008b6a250
2 files changed
+214
-26
No files matched your search
@@ -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() :
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
Reference in new issue
Block a user