fix(ai): liste de modeles corrompue et cles i18n brutes (BUG-009)
Locales chargees en cache no-store + SW_VERSION v4 pour eviter les cles brutes (fr.json obsolete). Picker : styles critiques en ligne (popover/liste/options), plafond de 200 modeles avec indicateur, et non-chevauchement entre la liste de modeles et la bulle de capacites.
This commit is contained in:
@@ -223,6 +223,16 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
### Corrigé
|
### Corrigé
|
||||||
|
|
||||||
|
- **BUG-009 — Assistant IA : liste de modèles corrompue et clés i18n brutes** — deux causes :
|
||||||
|
(1) les fichiers de locale n'étant pas *content-hashed*, un `fr.json` en cache HTTP affichait
|
||||||
|
les clés brutes (`ai.model_search`, `mobile_editor.*`, …) ; le chargement i18n utilise désormais
|
||||||
|
`cache: 'no-store'` et `SW_VERSION` est incrémenté pour purger les anciens caches du service
|
||||||
|
worker. (2) la liste de modèles (OpenRouter = plusieurs centaines d'entrées) pouvait se retrouver
|
||||||
|
non stylée si la feuille de style était en cache ; le picker applique maintenant les styles
|
||||||
|
critiques **en ligne** (popover, liste en colonne, options `display:block`), plafonne le rendu à
|
||||||
|
200 entrées avec un indicateur « … N autres — affinez la recherche », et la recherche filtre le
|
||||||
|
reste. Tests JSDOM (`tests/frontend/ai.test.mjs` +1).
|
||||||
|
|
||||||
- **BUG-007 — Assistant IA : menus `/` et `@` (navigation ↑/↓ et filtrage accentué)** — la
|
- **BUG-007 — Assistant IA : menus `/` et `@` (navigation ↑/↓ et filtrage accentué)** — la
|
||||||
détection des commandes/mentions et le filtrage des skills utilisaient des motifs ASCII
|
détection des commandes/mentions et le filtrage des skills utilisaient des motifs ASCII
|
||||||
(`[a-z0-9-]`, `\w`) : dès qu'un caractère accentué était saisi (`/résumé`, `@café`), le menu se
|
(`[a-z0-9-]`, `\w`) : dès qu'un caractère accentué était saisi (`/résumé`, `@café`), le menu se
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
|||||||
| *BUG-006* | Clé API DeepSeek réelle commitée en clair dans `.env.example` | 🟢 corrigé | P0 | 📄 docs + 🔐 sécurité | IA | `.env.example` | `git grep "sk-" .env.example` | `.env.example` : clé remplacée par un placeholder (`sk-xxx…`) + modèle `deepseek-chat` | ⚠️ **Rotation de la clé requise** : elle reste dans l'historique Git → révoquer/régénérer côté DeepSeek. Le fichier `.env` (réel) est bien gitignoré |
|
| *BUG-006* | Clé API DeepSeek réelle commitée en clair dans `.env.example` | 🟢 corrigé | P0 | 📄 docs + 🔐 sécurité | IA | `.env.example` | `git grep "sk-" .env.example` | `.env.example` : clé remplacée par un placeholder (`sk-xxx…`) + modèle `deepseek-chat` | ⚠️ **Rotation de la clé requise** : elle reste dans l'historique Git → révoquer/régénérer côté DeepSeek. Le fichier `.env` (réel) est bien gitignoré |
|
||||||
| *BUG-007* | Assistant IA : menus `/` et `@` — navigation clavier ↑/↓ inopérante et filtrage des skills cassé par les accents | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Taper `/résumé` ou `@café`, puis ↑/↓ | `bookslm.js` : motifs Unicode `\p{L}` (détection + sélection), recherche normalisée NFD (insensible aux accents), jeton `_menuSeq` (rendus async obsolètes), `scrollIntoView` de l'élément actif | Le menu se fermait dès la saisie d'un accent ; la frappe rapide pouvait écraser le menu avec un résultat obsolète. Tests : `tests/frontend/ai.test.mjs` (+3) |
|
| *BUG-007* | Assistant IA : menus `/` et `@` — navigation clavier ↑/↓ inopérante et filtrage des skills cassé par les accents | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Taper `/résumé` ou `@café`, puis ↑/↓ | `bookslm.js` : motifs Unicode `\p{L}` (détection + sélection), recherche normalisée NFD (insensible aux accents), jeton `_menuSeq` (rendus async obsolètes), `scrollIntoView` de l'élément actif | Le menu se fermait dès la saisie d'un accent ; la frappe rapide pouvait écraser le menu avec un résultat obsolète. Tests : `tests/frontend/ai.test.mjs` (+3) |
|
||||||
| *BUG-008* | Assistant IA : commande `@` — chemins accentués + contexte ad-hoc ignoré en mode Général | 🟢 corrigé | P1 | 📱 frontend + ⚙️ backend | IA | `frontend/js/bookslm.js`, `backend/bookslm_routes.py` | `@fichier-accentué` puis envoyer en mode Général | Frontend : détection Unicode + repli `vault=all` sans vault courant. Backend : `_resolve_system_prompt` résout le vault optionnel dès qu'un contexte `@` est présent (mode Général) | Le backend ignorait `extra_files`/`extra_directories` en mode Général (`vault_path is None`). Tests : `tests/test_bookslm.py` (+2) et `tests/frontend/ai.test.mjs` |
|
| *BUG-008* | Assistant IA : commande `@` — chemins accentués + contexte ad-hoc ignoré en mode Général | 🟢 corrigé | P1 | 📱 frontend + ⚙️ backend | IA | `frontend/js/bookslm.js`, `backend/bookslm_routes.py` | `@fichier-accentué` puis envoyer en mode Général | Frontend : détection Unicode + repli `vault=all` sans vault courant. Backend : `_resolve_system_prompt` résout le vault optionnel dès qu'un contexte `@` est présent (mode Général) | Le backend ignorait `extra_files`/`extra_directories` en mode Général (`vault_path is None`). Tests : `tests/test_bookslm.py` (+2) et `tests/frontend/ai.test.mjs` |
|
||||||
|
| *BUG-009* | Assistant IA : liste de modèles corrompue (art ASCII) et clés i18n brutes (`ai.model_search`) sous OpenRouter | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/ai.js`, `frontend/js/i18n.js`, `frontend/sw.js`, `frontend/style.css` | Ouvrir le menu modèle sous « openrouter » | Locale non content-hashée en cache HTTP → `cache:'no-store'` + bump `SW_VERSION` v4 ; styles critiques du picker appliqués **en ligne** (popover absolu, liste en colonne, options `display:block`) ; rendu plafonné à 200 modèles + indicateur « … N autres » | OpenRouter expose plusieurs centaines de modèles ; si `style.css` est en cache, la liste s'affichait en bloc inline (art ASCII) et les clés i18n brutes provenaient d'un `fr.json` obsolète. Tests : `tests/frontend/ai.test.mjs` (+1) |
|
||||||
|
|
||||||
### TODOs techniques (améliorations / nouvelles tâches)
|
### TODOs techniques (améliorations / nouvelles tâches)
|
||||||
|
|
||||||
@@ -141,7 +142,8 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
|||||||
| 2026-09-11 | BUG-003, BUG-004 | Correction | `backend/{main,indexer,export,pdf_reader,bookslm_routes}.py`, `backend/auth/router.py`, `.gitea/workflows/ci.yml`, `README.md`, `README.fr.md` | BUG-003: 33 erreurs mypy corrigées (annotations, gardes `None`, import `PROVIDERS` manquant → bug latent) + étape CI mypy rendue bloquante. BUG-004: lien `README.md` → `docs/CONTRIBUTING.md`. Vérifié: mypy 0 erreur, ruff OK, pytest 728 passed, frontend OK. | 🟢 corrigé (en attente vérif utilisateur) |
|
| 2026-09-11 | BUG-003, BUG-004 | Correction | `backend/{main,indexer,export,pdf_reader,bookslm_routes}.py`, `backend/auth/router.py`, `.gitea/workflows/ci.yml`, `README.md`, `README.fr.md` | BUG-003: 33 erreurs mypy corrigées (annotations, gardes `None`, import `PROVIDERS` manquant → bug latent) + étape CI mypy rendue bloquante. BUG-004: lien `README.md` → `docs/CONTRIBUTING.md`. Vérifié: mypy 0 erreur, ruff OK, pytest 728 passed, frontend OK. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||||
| 2026-09-11 | BUG-005 | Correction | `frontend/sw.js`, `frontend/index.html`, `frontend/js/sync.js`, `backend/main.py`, `.gitea/workflows/ci.yml`, `tests/frontend/sw.test.mjs`, `tests/test_api_main.py` | BUG-005: chargement mobile incomplet via Cloudflare. SW réécrit en **network-first** pour HTML/JS/CSS + caches versionnés ; précache corrigé (`/static/js/…`) ; kill-switch de session remplacé par une migration `localStorage` ponctuelle ; en-têtes `/static` passés de `immutable 1 an` à `no-cache` ; `index.html`/`manifest`/SPA en `no-cache` ; reload unique sur `controllerchange`. Vérifié: 754 tests backend, frontend 9+28+8 OK, ruff/mypy OK. | 🟢 corrigé (en attente vérif utilisateur) |
|
| 2026-09-11 | BUG-005 | Correction | `frontend/sw.js`, `frontend/index.html`, `frontend/js/sync.js`, `backend/main.py`, `.gitea/workflows/ci.yml`, `tests/frontend/sw.test.mjs`, `tests/test_api_main.py` | BUG-005: chargement mobile incomplet via Cloudflare. SW réécrit en **network-first** pour HTML/JS/CSS + caches versionnés ; précache corrigé (`/static/js/…`) ; kill-switch de session remplacé par une migration `localStorage` ponctuelle ; en-têtes `/static` passés de `immutable 1 an` à `no-cache` ; `index.html`/`manifest`/SPA en `no-cache` ; reload unique sur `controllerchange`. Vérifié: 754 tests backend, frontend 9+28+8 OK, ruff/mypy OK. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||||
| 2026-09-11 | BUG-006 | Correction sécurité | `.env.example` | Clé API DeepSeek réelle exposée dans `.env.example` → remplacée par un placeholder. **Rotation de la clé à faire côté DeepSeek** (présente dans l'historique Git). | 🟢 corrigé (rotation à confirmer par l'utilisateur) |
|
| 2026-09-11 | BUG-006 | Correction sécurité | `.env.example` | Clé API DeepSeek réelle exposée dans `.env.example` → remplacée par un placeholder. **Rotation de la clé à faire côté DeepSeek** (présente dans l'historique Git). | 🟢 corrigé (rotation à confirmer par l'utilisateur) |
|
||||||
| 2026-09-12 | BUG-007, BUG-008, #82 | Correction + refonte UI | `frontend/js/bookslm.js`, `frontend/js/ai.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `backend/bookslm_routes.py`, `tests/frontend/ai.test.mjs`, `tests/test_bookslm.py` | BUG-007 : motifs Unicode + recherche NFD insensible aux accents + navigation ↑/↓ fiabilisée (jeton de séquence, scrollIntoView). BUG-008 : contexte ad-hoc `@` pris en compte en mode Général (backend) + repli `vault=all`. #82 : section « Fournisseur & modèle » compacte (recherche modèle + bulle capacités ⓘ au survol/clic/appui long). Vérifié : tests frontend 41/41, `tests/test_bookslm.py` 47 passed. | 🟢 corrigé (en attente vérif utilisateur) |
|
| 2026-09-12 | BUG-007, BUG-008, #82 | Correction + refonte UI | `frontend/js/bookslm.js`, `frontend/js/ai.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `backend/bookslm_routes.py`, `tests/frontend/ai.test.mjs`, `tests/test_bookslm.py` | BUG-007 : motifs Unicode + recherche NFD insensible aux accents + navigation ↑/↓ fiabilisée (jeton de séquence, scrollIntoView). BUG-008 : contexte ad-hoc `@` pris en compte en mode Général (backend) + repli `vault=all`. #82 : section « Fournisseur & modèle » compacte (recherche modèle + bulle capacités ℹ️ au survol/clic/appui long). Vérifié : tests frontend 41/41, `tests/test_bookslm.py` 47 passed. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||||
|
| 2026-09-12 | BUG-009 | Correction | `frontend/js/ai.js`, `frontend/js/i18n.js`, `frontend/sw.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/ai.test.mjs` | BUG-009 : locale non content-hashée servie depuis le cache HTTP → clés i18n brutes ; passage en `cache:'no-store'` + `SW_VERSION` v4. Liste de modèles corrompue (OpenRouter, styles en cache) → styles critiques en ligne, options `display:block`, plafond 200 + indicateur. Vérifié : tests frontend 42/42. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -32,10 +32,18 @@
|
|||||||
|
|
||||||
## C. Tests — ✅ livré
|
## C. Tests — ✅ livré
|
||||||
- [x] `tests/frontend/ai.test.mjs` : filtrage accentué, mentions accentuées, navigation ↑/↓,
|
- [x] `tests/frontend/ai.test.mjs` : filtrage accentué, mentions accentuées, navigation ↑/↓,
|
||||||
recherche de modèle + bulle de capacités.
|
recherche de modèle + bulle de capacités, plafonnement des longues listes (OpenRouter).
|
||||||
- [x] `tests/test_bookslm.py` : contexte ad-hoc en mode Général (`/chat`).
|
- [x] `tests/test_bookslm.py` : contexte ad-hoc en mode Général (`/chat`).
|
||||||
|
|
||||||
## D. Points d'attention
|
## D. Durcissement anti-cache (BUG-009) — ✅ livré
|
||||||
|
- [x] Locales chargées avec `cache: 'no-store'` (`frontend/js/i18n.js`) : plus de clés brutes
|
||||||
|
affichées à cause d'un `fr.json` obsolète en cache HTTP.
|
||||||
|
- [x] `SW_VERSION` incrémenté (`frontend/sw.js`) pour purger les caches du service worker.
|
||||||
|
- [x] Styles critiques du picker appliqués **en ligne** (popover absolu, liste en colonne, options
|
||||||
|
`display:block`) pour rester corrects même si `style.css` est servi depuis un cache.
|
||||||
|
- [x] Rendu plafonné à 200 modèles (`MODEL_RENDER_LIMIT`) + indicateur `ai.model_more`.
|
||||||
|
|
||||||
|
## E. Points d'attention
|
||||||
- La table de capacités reste **statique** (`backend/model_capabilities.py`) : un modèle inconnu
|
- La table de capacités reste **statique** (`backend/model_capabilities.py`) : un modèle inconnu
|
||||||
retombe sur le défaut du fournisseur.
|
retombe sur le défaut du fournisseur.
|
||||||
- Le select masqué est conservé uniquement pour la rétro-compatibilité ; ne pas le supprimer sans
|
- Le select masqué est conservé uniquement pour la rétro-compatibilité ; ne pas le supprimer sans
|
||||||
|
|||||||
+31
-2
@@ -39,6 +39,11 @@ const AI_CAPABILITY_KEYS = [
|
|||||||
'video', 'audio_speech', 'audio_transcription', 'vision',
|
'video', 'audio_speech', 'audio_transcription', 'vision',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Maximum number of model options rendered at once. Providers such as
|
||||||
|
// OpenRouter expose hundreds of models; rendering them all makes the popover
|
||||||
|
// heavy and hard to scan, so the search box is the primary way to narrow down.
|
||||||
|
const MODEL_RENDER_LIMIT = 200;
|
||||||
|
|
||||||
function _readPicker() {
|
function _readPicker() {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(localStorage.getItem(PICKER_STORAGE_KEY) || '{}');
|
return JSON.parse(localStorage.getItem(PICKER_STORAGE_KEY) || '{}');
|
||||||
@@ -180,6 +185,11 @@ async function _buildPickerUI() {
|
|||||||
|
|
||||||
const modelPop = document.createElement('div');
|
const modelPop = document.createElement('div');
|
||||||
modelPop.className = 'ai-picker-model-pop hidden';
|
modelPop.className = 'ai-picker-model-pop hidden';
|
||||||
|
// Critical layout is applied inline too: if a stale stylesheet is served, the
|
||||||
|
// popover must still stack vertically instead of flowing as a block of text.
|
||||||
|
modelPop.style.cssText = 'position:absolute;top:calc(100% + 4px);left:0;z-index:60;'
|
||||||
|
+ 'min-width:220px;max-width:320px;background:var(--bg-primary);border:1px solid var(--border);'
|
||||||
|
+ 'border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.35);padding:6px;';
|
||||||
|
|
||||||
const modelSearch = document.createElement('input');
|
const modelSearch = document.createElement('input');
|
||||||
modelSearch.type = 'text';
|
modelSearch.type = 'text';
|
||||||
@@ -187,10 +197,12 @@ async function _buildPickerUI() {
|
|||||||
modelSearch.autocomplete = 'off';
|
modelSearch.autocomplete = 'off';
|
||||||
modelSearch.placeholder = t('ai.model_search');
|
modelSearch.placeholder = t('ai.model_search');
|
||||||
modelSearch.setAttribute('aria-label', t('ai.model_search'));
|
modelSearch.setAttribute('aria-label', t('ai.model_search'));
|
||||||
|
modelSearch.style.cssText = 'width:100%;box-sizing:border-box;';
|
||||||
|
|
||||||
const modelList = document.createElement('div');
|
const modelList = document.createElement('div');
|
||||||
modelList.className = 'ai-picker-model-list';
|
modelList.className = 'ai-picker-model-list';
|
||||||
modelList.setAttribute('role', 'listbox');
|
modelList.setAttribute('role', 'listbox');
|
||||||
|
modelList.style.cssText = 'max-height:200px;overflow-y:auto;display:flex;flex-direction:column;';
|
||||||
|
|
||||||
modelPop.appendChild(modelSearch);
|
modelPop.appendChild(modelSearch);
|
||||||
modelPop.appendChild(modelList);
|
modelPop.appendChild(modelList);
|
||||||
@@ -208,13 +220,20 @@ async function _buildPickerUI() {
|
|||||||
infoBtn.title = t('ai.model_info');
|
infoBtn.title = t('ai.model_info');
|
||||||
const capsPop = document.createElement('div');
|
const capsPop = document.createElement('div');
|
||||||
capsPop.className = 'ai-picker-caps-pop hidden';
|
capsPop.className = 'ai-picker-caps-pop hidden';
|
||||||
|
capsPop.style.cssText = 'position:absolute;top:calc(100% + 4px);right:0;z-index:60;'
|
||||||
|
+ 'min-width:220px;max-width:300px;background:var(--bg-primary);border:1px solid var(--border);'
|
||||||
|
+ 'border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.35);padding:8px 10px;';
|
||||||
capsHost.appendChild(infoBtn);
|
capsHost.appendChild(infoBtn);
|
||||||
capsHost.appendChild(capsPop);
|
capsHost.appendChild(capsPop);
|
||||||
|
|
||||||
let _hideCapsTimer = null;
|
let _hideCapsTimer = null;
|
||||||
let _longPressTimer = null;
|
let _longPressTimer = null;
|
||||||
let _longPressFired = false;
|
let _longPressFired = false;
|
||||||
const showCaps = () => { clearTimeout(_hideCapsTimer); capsPop.classList.remove('hidden'); };
|
const showCaps = () => {
|
||||||
|
clearTimeout(_hideCapsTimer);
|
||||||
|
_closePop(); // never overlap the model dropdown
|
||||||
|
capsPop.classList.remove('hidden');
|
||||||
|
};
|
||||||
const hideCaps = () => { _hideCapsTimer = setTimeout(() => capsPop.classList.add('hidden'), 140); };
|
const hideCaps = () => { _hideCapsTimer = setTimeout(() => capsPop.classList.add('hidden'), 140); };
|
||||||
infoBtn.addEventListener('mouseenter', showCaps);
|
infoBtn.addEventListener('mouseenter', showCaps);
|
||||||
infoBtn.addEventListener('focus', showCaps);
|
infoBtn.addEventListener('focus', showCaps);
|
||||||
@@ -261,6 +280,7 @@ async function _buildPickerUI() {
|
|||||||
function _renderList() {
|
function _renderList() {
|
||||||
const q = _normalizeText(modelSearch.value);
|
const q = _normalizeText(modelSearch.value);
|
||||||
const matches = _models.filter((m) => !q || _normalizeText(m).includes(q));
|
const matches = _models.filter((m) => !q || _normalizeText(m).includes(q));
|
||||||
|
const shown = matches.slice(0, MODEL_RENDER_LIMIT);
|
||||||
modelList.innerHTML = '';
|
modelList.innerHTML = '';
|
||||||
_activeIndex = -1;
|
_activeIndex = -1;
|
||||||
if (!matches.length) {
|
if (!matches.length) {
|
||||||
@@ -270,16 +290,24 @@ async function _buildPickerUI() {
|
|||||||
modelList.appendChild(empty);
|
modelList.appendChild(empty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
matches.forEach((model) => {
|
shown.forEach((model) => {
|
||||||
const option = document.createElement('button');
|
const option = document.createElement('button');
|
||||||
option.type = 'button';
|
option.type = 'button';
|
||||||
option.className = 'ai-picker-model-option';
|
option.className = 'ai-picker-model-option';
|
||||||
option.dataset.value = model;
|
option.dataset.value = model;
|
||||||
option.textContent = model || t('ai.model_default');
|
option.textContent = model || t('ai.model_default');
|
||||||
|
option.style.cssText = 'display:block;width:100%;text-align:left;';
|
||||||
if (model === modelSelect.value) option.classList.add('active');
|
if (model === modelSelect.value) option.classList.add('active');
|
||||||
option.addEventListener('mousedown', (e) => { e.preventDefault(); _pick(model); });
|
option.addEventListener('mousedown', (e) => { e.preventDefault(); _pick(model); });
|
||||||
modelList.appendChild(option);
|
modelList.appendChild(option);
|
||||||
});
|
});
|
||||||
|
if (matches.length > shown.length) {
|
||||||
|
const more = document.createElement('div');
|
||||||
|
more.className = 'ai-picker-model-more';
|
||||||
|
more.textContent = t('ai.model_more', { count: matches.length - shown.length });
|
||||||
|
more.style.cssText = 'font-size:0.7rem;color:var(--text-secondary);padding:6px 8px;text-align:center;';
|
||||||
|
modelList.appendChild(more);
|
||||||
|
}
|
||||||
_activeIndex = _visibleOptions().findIndex((el) => el.dataset.value === modelSelect.value);
|
_activeIndex = _visibleOptions().findIndex((el) => el.dataset.value === modelSelect.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,6 +321,7 @@ async function _buildPickerUI() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _openPop() {
|
function _openPop() {
|
||||||
|
capsPop.classList.add('hidden'); // never overlap the capability bubble
|
||||||
modelPop.classList.remove('hidden');
|
modelPop.classList.remove('hidden');
|
||||||
modelTrigger.setAttribute('aria-expanded', 'true');
|
modelTrigger.setAttribute('aria-expanded', 'true');
|
||||||
modelSearch.value = '';
|
modelSearch.value = '';
|
||||||
|
|||||||
+3
-1
@@ -136,7 +136,9 @@ export async function initI18n() {
|
|||||||
|
|
||||||
async function _fetchLocale(locale) {
|
async function _fetchLocale(locale) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/static/locales/' + locale + '.json');
|
// ``no-store`` bypasses the HTTP cache: locale files are not content-hashed
|
||||||
|
// and a stale copy would surface raw keys in the UI (missing translations).
|
||||||
|
const resp = await fetch('/static/locales/' + locale + '.json', { cache: 'no-store' });
|
||||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||||
return await resp.json();
|
return await resp.json();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -130,6 +130,7 @@
|
|||||||
"ai.model_settings": "Provider & model",
|
"ai.model_settings": "Provider & model",
|
||||||
"ai.model_search": "Search a model…",
|
"ai.model_search": "Search a model…",
|
||||||
"ai.model_info": "Model capabilities",
|
"ai.model_info": "Model capabilities",
|
||||||
|
"ai.model_more": "… {count} more — refine your search",
|
||||||
"ai.add_context": "Add context",
|
"ai.add_context": "Add context",
|
||||||
"ai.attach_image": "Attach an image",
|
"ai.attach_image": "Attach an image",
|
||||||
"ai.cap_audio_speech": "Audio Speech",
|
"ai.cap_audio_speech": "Audio Speech",
|
||||||
|
|||||||
@@ -130,6 +130,7 @@
|
|||||||
"ai.model_settings": "Fournisseur & modèle",
|
"ai.model_settings": "Fournisseur & modèle",
|
||||||
"ai.model_search": "Rechercher un modèle…",
|
"ai.model_search": "Rechercher un modèle…",
|
||||||
"ai.model_info": "Capacités du modèle",
|
"ai.model_info": "Capacités du modèle",
|
||||||
|
"ai.model_more": "… {count} autres — affinez la recherche",
|
||||||
"ai.add_context": "Ajouter un contexte",
|
"ai.add_context": "Ajouter un contexte",
|
||||||
"ai.attach_image": "Joindre une image",
|
"ai.attach_image": "Joindre une image",
|
||||||
"ai.cap_audio_speech": "Audio Speech",
|
"ai.cap_audio_speech": "Audio Speech",
|
||||||
|
|||||||
+4
-3
@@ -9385,11 +9385,12 @@ body.bookslm-resizing { cursor: ew-resize; user-select: none; }
|
|||||||
background: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border);
|
background: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border);
|
||||||
border-radius: 6px; padding: 5px 8px; margin-bottom: 4px; }
|
border-radius: 6px; padding: 5px 8px; margin-bottom: 4px; }
|
||||||
.ai-picker-model-list { max-height: 200px; overflow-y: auto; display: flex; flex-direction: column; }
|
.ai-picker-model-list { max-height: 200px; overflow-y: auto; display: flex; flex-direction: column; }
|
||||||
.ai-picker-model-option { text-align: left; font-size: 0.72rem; padding: 5px 8px; border-radius: 5px;
|
.ai-picker-model-option { display: block; width: 100%; text-align: left; font-size: 0.72rem;
|
||||||
background: none; border: none; color: var(--text-primary); cursor: pointer;
|
padding: 5px 8px; border-radius: 5px; background: none; border: none; color: var(--text-primary);
|
||||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.ai-picker-model-option:hover, .ai-picker-model-option.active { background: var(--surface2); }
|
.ai-picker-model-option:hover, .ai-picker-model-option.active { background: var(--surface2); }
|
||||||
.ai-picker-model-empty { font-size: 0.7rem; color: var(--text-secondary); padding: 6px 8px; text-align: center; }
|
.ai-picker-model-empty { font-size: 0.7rem; color: var(--text-secondary); padding: 6px 8px; text-align: center; }
|
||||||
|
.ai-picker-model-more { font-size: 0.7rem; color: var(--text-secondary); padding: 6px 8px; text-align: center; }
|
||||||
/* Capability info button (ⓘ) + informational bubble. */
|
/* Capability info button (ⓘ) + informational bubble. */
|
||||||
.ai-picker-caps-host { position: relative; display: inline-flex; }
|
.ai-picker-caps-host { position: relative; display: inline-flex; }
|
||||||
.ai-picker-info { background: none; border: none; cursor: pointer; color: var(--text-secondary);
|
.ai-picker-info { background: none; border: none; cursor: pointer; color: var(--text-secondary);
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
/* ObsiGate — Service Worker (PWA offline + push)
|
/* ObsiGate — Service Worker (PWA offline + push)
|
||||||
* Version: 3 (2026-09-11)
|
* Version: 4 (2026-09-12)
|
||||||
*
|
*
|
||||||
* Caching policy (fixes stale-asset loads behind Cloudflare / mobile):
|
* Caching policy (fixes stale-asset loads behind Cloudflare / mobile):
|
||||||
* - navigations & code (HTML / JS / CSS / manifest) : NETWORK-FIRST
|
* - navigations & code (HTML / JS / CSS / manifest) : NETWORK-FIRST
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
|
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
|
||||||
* a separate store. Bumping SW_VERSION invalidates it on every release.
|
* a separate store. Bumping SW_VERSION invalidates it on every release.
|
||||||
*/
|
*/
|
||||||
const SW_VERSION = 'v3';
|
const SW_VERSION = 'v4';
|
||||||
const CODE_CACHE = `obsigate-code-${SW_VERSION}`;
|
const CODE_CACHE = `obsigate-code-${SW_VERSION}`;
|
||||||
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
|
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
|
||||||
const API_CACHE = `obsigate-api-${SW_VERSION}`;
|
const API_CACHE = `obsigate-api-${SW_VERSION}`;
|
||||||
|
|||||||
@@ -959,6 +959,36 @@ async function main() {
|
|||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── 42. Picker handles very long model lists (OpenRouter) ──
|
||||||
|
await test("picker caps long model lists and keeps options block-laid-out", async () => {
|
||||||
|
localStorage.clear();
|
||||||
|
const models = Array.from({ length: 250 }, (_, i) => `vendor-${i}/model-${i}:free`);
|
||||||
|
globalThis.fetch = async (url) => {
|
||||||
|
const u = String(url);
|
||||||
|
if (u.includes("/api/ai/status")) {
|
||||||
|
return { ok: true, status: 200, json: async () => ({ configured: true, providers: { openrouter: { available: true } } }) };
|
||||||
|
}
|
||||||
|
if (u.includes("/api/config/ai-models")) {
|
||||||
|
return { ok: true, status: 200, json: async () => ({ models, source: "live" }) };
|
||||||
|
}
|
||||||
|
if (u.includes("/api/ai/model-capabilities")) {
|
||||||
|
return { ok: true, status: 200, json: async () => ({ capabilities: { chat: true, vision: true } }) };
|
||||||
|
}
|
||||||
|
return { ok: true, status: 200, json: async () => ({}) };
|
||||||
|
};
|
||||||
|
localStorage.setItem("obsigate_ai_picker", JSON.stringify({ provider: "openrouter" }));
|
||||||
|
const picker = await ai.buildAIPickerUI();
|
||||||
|
document.body.appendChild(picker);
|
||||||
|
await sleep(30);
|
||||||
|
picker.querySelector(".ai-picker-model-trigger").click();
|
||||||
|
const options = picker.querySelectorAll(".ai-picker-model-option");
|
||||||
|
assert.equal(options.length, 200, "rendered options are capped");
|
||||||
|
assert.ok(picker.querySelector(".ai-picker-model-more"), "a 'more results' hint is shown");
|
||||||
|
assert.ok(options[0].style.display === "block", "options are block-laid-out even without CSS");
|
||||||
|
picker.remove();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
// ── Summary ──
|
// ── Summary ──
|
||||||
console.log(`\n${passCount}/${testCount} tests passed`);
|
console.log(`\n${passCount}/${testCount} tests passed`);
|
||||||
if (passCount !== testCount) {
|
if (passCount !== testCount) {
|
||||||
|
|||||||
Reference in New Issue
Block a user