feat(ai): section Fournisseur & modele compacte + correctifs menus @/ (#82, BUG-007, BUG-008)
CI / lint (push) Successful in 1m13s
CI / security (push) Successful in 43s
CI / test (push) Successful in 2m33s
CI / build (push) Successful in 39s
CI / e2e (push) Successful in 10m32s

Refonte de la section Fournisseur & modele de l'assistant : capacites regroupees dans une bulle d'information (survol/clic/appui long) au lieu d'une liste permanente, et liste de modeles avec recherche. Correctifs : detection Unicode + filtrage insensible aux accents des commandes / et mentions @, navigation clavier fiable, et prise en compte du contexte ad-hoc en mode General cote backend.
This commit is contained in:
2026-09-12 12:40:03 -04:00
parent f049e208b6
commit d6b7c0e9cb
14 changed files with 643 additions and 93 deletions
+23
View File
@@ -14,6 +14,15 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Ajouté
- **#82 Assistant IA — Section « Fournisseur & modèle » compacte** — la barre de sélection du
modèle devient **discrète** : la liste complète des types d'endpoints (Chat, Embeddings, Vision…)
est remplacée par un **bouton d'information ⓘ** qui affiche les capacités dans une **bulle** au
survol, au clic ou par **appui long** (mobile). Le choix du modèle passe par une **liste
déroulante avec recherche** (filtrage insensible aux accents) au lieu d'un `<select>` natif, tout
en conservant un select masqué pour les commandes admin (`/model`). Le picker partagé
(`frontend/js/ai.js`) est utilisé par l'assistant et la barre d'édition IA. Tests JSDOM
(`tests/frontend/ai.test.mjs` +1).
- **#81 Assistant IA — Commandes `@`/`/`, skills, images & capacités des modèles** — l'assistant
devient plus flexible et multimodal. **Panneau redimensionnable** à la souris (largeur 320–1000 px
persistée). **Commande `@`** pour attacher des contextes ad-hoc (fichiers, répertoires) au contexte
@@ -214,6 +223,20 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Corrigé
- **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
(`[a-z0-9-]`, `\w`) : dès qu'un caractère accentué était saisi (`/résumé`, `@café`), le menu se
fermait. Les motifs sont désormais **Unicode** (`\p{L}`) et la recherche est **insensible aux
accents** (normalisation NFD). La navigation clavier est fiabilisée par un **jeton de séquence**
qui ignore les rendus asynchrones obsolètes (frappe rapide) et fait défiler l'élément actif dans
la vue (`scrollIntoView`). Tests JSDOM (`tests/frontend/ai.test.mjs` +3).
- **BUG-008 — Assistant IA : commande `@` et contexte ad-hoc en mode Général** — deux causes :
(1) le backend ignorait les fichiers/répertoires ad-hoc en mode Général car `_resolve_system_prompt`
ne résolvait pas de vault (`backend/bookslm_routes.py`) ; le vault optionnel est maintenant résolu
dès que du contexte `@` est présent. (2) sans vault courant, la recherche de mention échouait ;
elle bascule désormais sur `vault=all`. Tests backend (`tests/test_bookslm.py` +2) et frontend.
- **#77 Desktop — bannière de premier lancement réaffichée à chaque démarrage** —
l'état du wizard « Choisissez votre vault » n'était mémorisé que dans le
`localStorage` de la webview et le paramètre `hasVaultPath` n'était jamais
+4
View File
@@ -163,6 +163,10 @@ def _resolve_system_prompt(req, current_user) -> str:
vault_path: Path | None = None
if mode != "general":
_, vault_path = _resolve_vault_path(req.vault, current_user)
elif getattr(req, "extra_files", None) or getattr(req, "extra_directories", None):
# General mode has no base context, but ad-hoc files/directories added
# with `@` still need a vault to be read from.
vault_path = _resolve_optional_vault_path(req, current_user)
context = _build_context(
mode,
+4 -1
View File
@@ -14,7 +14,7 @@
- **Projet** : ObsiGate — Porte d'entrée web pour vaults Obsidian
- **Stack** : Python 3.11+ (backend FastAPI) · JavaScript/Vanilla (frontend) · Tauri/Rust (desktop)
- **Dernière mise à jour** : 2026-09-11
- **Dernière mise à jour** : 2026-09-12
---
@@ -116,6 +116,8 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *BUG-004* | Lien cassé vers `CONTRIBUTING.md` dans `README.md` | 🟢 corrigé | P3 | 📄 docs | IA | `README.md` | Cliquer le lien « Contributing » | `README.md` : lien → `docs/CONTRIBUTING.md` (+ `DELIVERY_WORKFLOW.md`) ; arbre du projet corrigé | `README.fr.md` pointait déjà correctement vers `./docs/CONTRIBUTING.md` |
| *BUG-005* | Sur mobile via Cloudflare (`og.dracodev.net`), le site ne charge pas complètement même après purge des caches (téléphone + Cloudflare) | 🟢 corrigé | P1 | 📱 frontend + ⚙️ backend | IA | `frontend/sw.js`, `frontend/index.html`, `frontend/js/sync.js`, `backend/main.py` | Ouvrir `og.dracodev.net` sur mobile (PWA installée), purger les caches, recharger | SW **network-first** pour le code + caches versionnés (`SW_VERSION`) ; précache corrigé (`/static/js/…`) ; migration ponctuelle `localStorage` ; `Cache-Control: no-cache` (le middleware renvoyait `immutable` 1 an sur `/static`) ; tests `tests/frontend/sw.test.mjs` + `TestStaticCaching` | Cause : SW cache-first à nom fixe + en-têtes `immutable` non fingerprinted → ancien build servi indéfiniment (Cache Storage ≠ cache navigateur/CF). Vérifié : 754 tests backend, frontend OK |
| *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-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` |
### TODOs techniques (améliorations / nouvelles tâches)
@@ -139,6 +141,7 @@ 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-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-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) |
---
+2 -1
View File
@@ -95,6 +95,7 @@
| 79 | Assistant IA — Outils (function calling) & serveur MCP | 2.3.0 | [features/ai-tools-mcp.md](./features/ai-tools-mcp.md) |
| 80 | Assistant IA — Rendu Markdown, liens fichiers/paths & sessions | 2.3.0 | [features/ai-assistant-ux.md](./features/ai-assistant-ux.md) |
| 81 | Assistant IA — Commandes (`@`, `/`), skills, images & capacités des modèles | 2.3.0 | [features/ai-assistant-commands.md](./features/ai-assistant-commands.md) |
| 82 | Assistant IA — Section « Fournisseur & modèle » compacte (recherche modèle + info capacités) | 2.3.0 | [features/ai-provider-picker.md](./features/ai-provider-picker.md) |
| 69 | Éditeur mobile natif — Interface tactile optimisée | 2.3.0 | [features/mobile-editor.md](./features/mobile-editor.md) |
| 70 | Recherche sémantique — Embeddings vectoriels (hybride TF-IDF + RRF) | 2.3.0 | [features/semantic-search.md](./features/semantic-search.md) |
| 77 | Application Desktop native — Tauri | 🔵 en cours | [features/desktop-tauri.md](./features/desktop-tauri.md) |
@@ -105,7 +106,7 @@
| Priorité | Items | Effort total estimé |
|---|---|---|
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–81 | ~107 jours réalisés |
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–82 | ~107 jours réalisés |
| 🔵 P2 restant | #77 Desktop : signature de code (non retenue), 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) | ~0,5-1 jour |
| ⚪ P4 restant | #73 Sync (6-8j) | 6-8 jours |
| **Total restant** | **2 items + finitions** | **~7-10 jours** |
+3
View File
@@ -52,6 +52,9 @@
dans `GET /api/config/ai-models`.
- [x] Affichage ☑/□ dans le picker de l'assistant (`frontend/js/ai.js`) et dans le panneau de
configuration (`#cfg-ai-default-model-caps`, `frontend/js/config.js`).
- [x] **#82** — dans le picker de l'assistant, la liste permanente est remplacée par un bouton
d'information ⓘ et une bulle au survol/clic/appui long, avec recherche de modèle. Détail :
[ai-provider-picker.md](./ai-provider-picker.md).
## F. Tests & documentation — ✅ livré
- [x] Backend : `tests/test_model_capabilities.py`, `tests/test_skills.py`, `tests/test_ai_vision.py`,
+42
View File
@@ -0,0 +1,42 @@
# #82 — Assistant IA — Section « Fournisseur & modèle » compacte
> **Statut :** ✅ Livré (en attente de validation utilisateur)
> **Effort :** 1 jour | **Impact :** 🟢
> **Références :** [Roadmap](../ROADMAP.md) · [Assistant IA #81](./ai-assistant-commands.md) · [Changelog](../../CHANGELOG.md)
- **Description :** rendre la barre de sélection du modèle de l'assistant IA **plus discrète** tout
en conservant les informations utiles :
1. **Capacités via bulle** — la liste complète des types d'endpoints (Chat, Embeddings, Rerank,
Images, Video, Audio Speech, Audio Transcriptions, Vision) n'est plus affichée en permanence.
Un bouton **ⓘ** révèle une bulle d'information au **survol**, au **clic** ou par **appui long**
(mobile).
2. **Recherche de modèle** — le `<select>` natif est remplacé par une liste déroulante compacte
dotée d'un **champ de recherche** qui filtre les modèles en tapant (insensible à la casse et
aux accents).
3. **Compatibilité** — un `<select class="ai-picker-model">` masqué reste présent (source de
vérité) pour les commandes admin `/model` et l'état persisté `obsigate_ai_picker`.
## A. Picker partagé — ✅ livré
- [x] `frontend/js/ai.js` : `buildAIPickerUI()` réécrit (provider compact + combobox modèle + bulle
capacités). Le picker reste partagé par l'assistant (`bookslm.js`) et la barre d'édition IA.
- [x] `renderCapabilityList()` conservé et affiché dans la bulle (le panneau de configuration
`#cfg-ai-default-model-caps` continue de l'utiliser directement).
- [x] Fermeture des popovers au clic extérieur via un unique listener délégué (pas de fuite).
- [x] i18n FR/EN (`ai.model_search`, `ai.model_info`).
## B. Correctifs associés — ✅ livré
- [x] **BUG-007** — détection `/` et `@` Unicode + filtrage insensible aux accents + navigation
clavier ↑/↓ fiabilisée (jeton de séquence, `scrollIntoView`).
- [x] **BUG-008** — contexte ad-hoc `@` pris en compte en mode Général côté backend
(`backend/bookslm_routes.py`) et recherche de mention sur `vault=all` sans vault courant.
## C. Tests — ✅ livré
- [x] `tests/frontend/ai.test.mjs` : filtrage accentué, mentions accentuées, navigation ↑/↓,
recherche de modèle + bulle de capacités.
- [x] `tests/test_bookslm.py` : contexte ad-hoc en mode Général (`/chat`).
## D. Points d'attention
- La table de capacités reste **statique** (`backend/model_capabilities.py`) : un modèle inconnu
retombe sur le défaut du fournisseur.
- Le select masqué est conservé uniquement pour la rétro-compatibilité ; ne pas le supprimer sans
migrer `_cmdSwitchModel` / `_applyPickerSelection` dans `frontend/js/bookslm.js`.
+3 -2
View File
@@ -4287,8 +4287,9 @@
</li>
<li data-i18n="help.assistant_images">
Collez une image dans la zone de saisie (ou joignez-la) pour
l'analyser avec un modèle compatible vision ; les capacités du
modèle sélectionné sont affichées.
l'analyser avec un modèle compatible vision. Le bouton ⓘ affiche
les capacités du modèle sélectionné, et la liste des modèles est
filtrable par recherche.
</li>
</ul>
</section>
+239 -66
View File
@@ -77,10 +77,44 @@ function renderCapabilityList(caps) {
return box;
}
/** Accent-insensitive, case-insensitive normalization for model search. */
function _normalizeText(value) {
return String(value || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
// One delegated document listener closes every picker popover when the user
// clicks outside it. Bound once, whatever the number of pickers mounted.
let _pickerOutsideBound = false;
function _bindPickerOutside() {
if (_pickerOutsideBound) return;
_pickerOutsideBound = true;
document.addEventListener('click', (e) => {
document.querySelectorAll('.ai-picker-caps-host').forEach((host) => {
if (host.contains(e.target)) return;
const pop = host.querySelector('.ai-picker-caps-pop');
if (pop) pop.classList.add('hidden');
});
document.querySelectorAll('.ai-picker-model-combo').forEach((combo) => {
if (combo.contains(e.target)) return;
const pop = combo.querySelector('.ai-picker-model-pop');
const trigger = combo.querySelector('.ai-picker-model-trigger');
if (pop) pop.classList.add('hidden');
if (trigger) trigger.setAttribute('aria-expanded', 'false');
});
});
}
/**
* Build a provider+model picker that appears in every section's AI toolbar
* (Forge editor, BooksLM, etc.). The picker reads /api/ai/status to discover
* available providers, then /api/config/ai-models?provider=X to list models.
*
* Discreet layout: a compact provider select, a searchable model combobox and
* an info button (ⓘ) that reveals the model capabilities in a bubble on hover,
* click or long-press (mobile) instead of always listing every endpoint type.
*/
async function _buildPickerUI() {
const pickerState = _readPicker();
@@ -99,36 +133,14 @@ async function _buildPickerUI() {
const wrap = document.createElement('div');
wrap.className = 'ai-picker';
Object.assign(wrap.style, {
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
marginLeft: '8px',
paddingLeft: '8px',
borderLeft: '1px solid var(--border)',
fontSize: '0.7rem',
color: 'var(--text-muted)',
flexShrink: '0',
zIndex: '1',
});
// Provider select
// ── Provider select (compact) ──
const providerLabel = document.createElement('span');
providerLabel.className = 'ai-picker-label';
providerLabel.textContent = t('ai.provider') + ':';
wrap.appendChild(providerLabel);
const providerSelect = document.createElement('select');
providerSelect.className = 'ai-picker-select';
Object.assign(providerSelect.style, {
fontSize: '0.7rem',
background: 'var(--surface)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
borderRadius: '4px',
padding: '2px 4px',
cursor: 'pointer',
});
const defaultOpt = document.createElement('option');
defaultOpt.value = '';
defaultOpt.textContent = t('ai.provider_default');
@@ -141,31 +153,178 @@ async function _buildPickerUI() {
providerSelect.appendChild(opt);
});
// Model select (sibling of provider)
// Hidden native select kept as the source of truth so the assistant admin
// commands (`/model`) and the stored picker state keep working unchanged.
const modelSelect = document.createElement('select');
modelSelect.className = 'ai-picker-model';
Object.assign(modelSelect.style, {
fontSize: '0.7rem',
background: 'var(--surface)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
borderRadius: '4px',
padding: '2px 4px',
cursor: 'pointer',
minWidth: '120px',
maxWidth: '220px',
});
const placeholderOpt = document.createElement('option');
placeholderOpt.value = '';
placeholderOpt.textContent = t('ai.model_default');
modelSelect.appendChild(placeholderOpt);
modelSelect.hidden = true;
// Capability checklist, refreshed whenever the selected model changes.
// ── Searchable model combobox ──
const combo = document.createElement('div');
combo.className = 'ai-picker-model-combo';
const modelTrigger = document.createElement('button');
modelTrigger.type = 'button';
modelTrigger.className = 'ai-picker-model-trigger';
modelTrigger.setAttribute('aria-haspopup', 'listbox');
modelTrigger.setAttribute('aria-expanded', 'false');
const modelName = document.createElement('span');
modelName.className = 'ai-picker-model-name';
modelName.textContent = pickerState.model || t('ai.model_default');
const chevron = document.createElement('span');
chevron.className = 'ai-picker-model-chevron';
chevron.textContent = '▾';
modelTrigger.appendChild(modelName);
modelTrigger.appendChild(chevron);
const modelPop = document.createElement('div');
modelPop.className = 'ai-picker-model-pop hidden';
const modelSearch = document.createElement('input');
modelSearch.type = 'text';
modelSearch.className = 'ai-picker-model-search';
modelSearch.autocomplete = 'off';
modelSearch.placeholder = t('ai.model_search');
modelSearch.setAttribute('aria-label', t('ai.model_search'));
const modelList = document.createElement('div');
modelList.className = 'ai-picker-model-list';
modelList.setAttribute('role', 'listbox');
modelPop.appendChild(modelSearch);
modelPop.appendChild(modelList);
combo.appendChild(modelTrigger);
combo.appendChild(modelPop);
// ── Capabilities info button + bubble ──
const capsHost = document.createElement('div');
capsHost.className = 'ai-picker-caps-host';
const infoBtn = document.createElement('button');
infoBtn.type = 'button';
infoBtn.className = 'ai-picker-info hidden';
infoBtn.textContent = 'ℹ️';
infoBtn.setAttribute('aria-label', t('ai.model_info'));
infoBtn.title = t('ai.model_info');
const capsPop = document.createElement('div');
capsPop.className = 'ai-picker-caps-pop hidden';
capsHost.appendChild(infoBtn);
capsHost.appendChild(capsPop);
let _hideCapsTimer = null;
let _longPressTimer = null;
let _longPressFired = false;
const showCaps = () => { clearTimeout(_hideCapsTimer); capsPop.classList.remove('hidden'); };
const hideCaps = () => { _hideCapsTimer = setTimeout(() => capsPop.classList.add('hidden'), 140); };
infoBtn.addEventListener('mouseenter', showCaps);
infoBtn.addEventListener('focus', showCaps);
infoBtn.addEventListener('mouseleave', hideCaps);
infoBtn.addEventListener('blur', hideCaps);
infoBtn.addEventListener('click', (e) => {
e.stopPropagation();
// A long-press already opened the bubble: swallow the synthetic click.
if (_longPressFired) { _longPressFired = false; return; }
capsPop.classList.toggle('hidden');
});
capsPop.addEventListener('mouseenter', showCaps);
capsPop.addEventListener('mouseleave', hideCaps);
infoBtn.addEventListener('touchstart', () => {
_longPressFired = false;
_longPressTimer = setTimeout(() => { _longPressFired = true; showCaps(); }, 450);
}, { passive: true });
const _cancelLongPress = () => clearTimeout(_longPressTimer);
infoBtn.addEventListener('touchend', _cancelLongPress);
infoBtn.addEventListener('touchcancel', () => { _cancelLongPress(); _longPressFired = false; });
_bindPickerOutside();
// ── Model list state ──
let _models = [];
let _activeIndex = -1;
const _visibleOptions = () => Array.from(modelList.querySelectorAll('.ai-picker-model-option'));
function _syncTrigger() {
modelName.textContent = modelSelect.value || t('ai.model_default');
}
function _highlight(index) {
const options = _visibleOptions();
if (!options.length) { _activeIndex = -1; return; }
_activeIndex = ((index % options.length) + options.length) % options.length;
options.forEach((el, i) => el.classList.toggle('active', i === _activeIndex));
const active = options[_activeIndex];
if (active && typeof active.scrollIntoView === 'function') {
active.scrollIntoView({ block: 'nearest' });
}
}
function _renderList() {
const q = _normalizeText(modelSearch.value);
const matches = _models.filter((m) => !q || _normalizeText(m).includes(q));
modelList.innerHTML = '';
_activeIndex = -1;
if (!matches.length) {
const empty = document.createElement('div');
empty.className = 'ai-picker-model-empty';
empty.textContent = t('ai.no_results');
modelList.appendChild(empty);
return;
}
matches.forEach((model) => {
const option = document.createElement('button');
option.type = 'button';
option.className = 'ai-picker-model-option';
option.dataset.value = model;
option.textContent = model || t('ai.model_default');
if (model === modelSelect.value) option.classList.add('active');
option.addEventListener('mousedown', (e) => { e.preventDefault(); _pick(model); });
modelList.appendChild(option);
});
_activeIndex = _visibleOptions().findIndex((el) => el.dataset.value === modelSelect.value);
}
function _pick(model) {
modelSelect.value = model;
delete pickerState.capabilities;
_writePicker(pickerState);
_syncTrigger();
_closePop();
modelSelect.dispatchEvent(new Event('change'));
}
function _openPop() {
modelPop.classList.remove('hidden');
modelTrigger.setAttribute('aria-expanded', 'true');
modelSearch.value = '';
_renderList();
modelSearch.focus();
}
function _closePop() {
modelPop.classList.add('hidden');
modelTrigger.setAttribute('aria-expanded', 'false');
modelSearch.value = '';
}
modelTrigger.addEventListener('click', (e) => {
e.stopPropagation();
if (modelPop.classList.contains('hidden')) _openPop();
else _closePop();
});
modelSearch.addEventListener('input', _renderList);
modelSearch.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') { e.preventDefault(); _highlight(_activeIndex < 0 ? 0 : _activeIndex + 1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); _highlight(_activeIndex < 0 ? -1 : _activeIndex - 1); }
else if (e.key === 'Enter') {
e.preventDefault();
const options = _visibleOptions();
const chosen = options[_activeIndex] || options[0];
if (chosen) _pick(chosen.dataset.value);
} else if (e.key === 'Escape') { e.preventDefault(); _closePop(); }
});
async function _updateCaps() {
capsHost.innerHTML = '';
capsPop.innerHTML = '';
infoBtn.classList.add('hidden');
if (!pickerState.provider || !pickerState.model) {
delete pickerState.capabilities;
_writePicker(pickerState);
@@ -175,42 +334,58 @@ async function _buildPickerUI() {
if (!caps) return;
pickerState.capabilities = caps;
_writePicker(pickerState);
capsHost.appendChild(renderCapabilityList(caps));
const title = document.createElement('div');
title.className = 'ai-picker-caps-title';
title.textContent = t('ai.model_info');
capsPop.appendChild(title);
capsPop.appendChild(renderCapabilityList(caps));
infoBtn.classList.remove('hidden');
}
async function _loadModels(provider) {
_models = [];
modelSelect.innerHTML = '';
const ph = document.createElement('option');
ph.value = '';
ph.textContent = t('ai.model_loading');
modelSelect.appendChild(ph);
_syncTrigger();
if (!provider) {
_renderList();
await _updateCaps();
return;
}
try {
const data = await api(`/api/config/ai-models?provider=${encodeURIComponent(provider)}`);
modelSelect.innerHTML = '';
const def = document.createElement('option');
def.value = '';
def.textContent = t('ai.model_default') + (data.source === 'fallback' ? ` (${t('ai.model_offline')})` : '');
def.textContent = t('ai.model_default');
modelSelect.appendChild(def);
_models = [''];
(data.models || []).forEach((m) => {
const opt = document.createElement('option');
opt.value = m;
opt.textContent = m;
if (m === pickerState.model && provider === pickerState.provider) opt.selected = true;
modelSelect.appendChild(opt);
_models.push(m);
});
// Keep an explicitly stored model selectable even if the API omits it.
if (pickerState.model && !_models.includes(pickerState.model)) {
const opt = document.createElement('option');
opt.value = pickerState.model;
opt.textContent = pickerState.model;
modelSelect.appendChild(opt);
_models.push(pickerState.model);
}
// If the provider default model is known, reflect it so capabilities
// are shown even when the user keeps the "default" placeholder.
if (!pickerState.model && data.models && data.models.length) {
pickerState.model = data.models[0];
_writePicker(pickerState);
}
modelSelect.value = pickerState.model || '';
_syncTrigger();
await _updateCaps();
} catch {
_models = [''];
_syncTrigger();
await _updateCaps();
} catch (err) {
modelSelect.innerHTML = '';
const opt = document.createElement('option');
opt.value = '';
opt.textContent = t('ai.model_load_error');
modelSelect.appendChild(opt);
}
}
@@ -220,32 +395,30 @@ async function _buildPickerUI() {
pickerState.model = null;
delete pickerState.capabilities;
_writePicker(pickerState);
capsHost.innerHTML = '';
if (pickerState.provider) {
_loadModels(pickerState.provider);
} else {
modelSelect.innerHTML = '';
const ph = document.createElement('option');
ph.value = '';
ph.textContent = t('ai.model_default');
modelSelect.appendChild(ph);
}
_closePop();
_loadModels(pickerState.provider);
});
modelSelect.addEventListener('change', () => {
pickerState.model = modelSelect.value || null;
delete pickerState.capabilities;
_writePicker(pickerState);
_syncTrigger();
_updateCaps();
});
// Load models for the initial provider selection
if (pickerState.provider && providerNames.includes(pickerState.provider)) {
_loadModels(pickerState.provider);
} else {
_syncTrigger();
_renderList();
}
wrap.appendChild(providerLabel);
wrap.appendChild(providerSelect);
wrap.appendChild(modelSelect);
wrap.appendChild(combo);
wrap.appendChild(capsHost);
return wrap;
}
+55 -19
View File
@@ -30,6 +30,18 @@ const PANEL_MIN_WIDTH = 320;
const PANEL_MAX_WIDTH = 1000;
const PANEL_WIDTH_KEY = 'obsigate-bookslm-width';
/**
* Accent- and case-insensitive normalization used to match `/` commands and
* `@` mentions: skill ids/labels and vault paths may contain accented
* characters (é, à, ç…), which the composer regexes and filters must accept.
*/
function normalizeSearch(value) {
return String(value || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
/**
* Gather the files currently open across the singleton TabManager and every
* split-view pane (window globals avoid a circular import with ui.js).
@@ -93,6 +105,8 @@ class BooksLM {
this._menuItems = [];
this._menuIndex = 0;
this._menuKind = null;
// Monotonic token used to drop stale async menu renders (fast typing).
this._menuSeq = 0;
}
_readAgentMode() {
@@ -727,12 +741,13 @@ class BooksLM {
if (!textarea) return;
const pos = textarea.selectionStart;
const before = textarea.value.slice(0, pos);
const command = before.match(/(?:^|\s)\/([a-z0-9-]*)$/i);
// Unicode-aware so accented skill names / paths keep the menu open.
const command = before.match(/(?:^|\s)\/([\p{L}\p{N}_-]*)$/u);
if (command) {
this._showCommandMenu(command[1].toLowerCase());
this._showCommandMenu(command[1]);
return;
}
const mention = before.match(/(?:^|\s)@([\w./-]*)$/);
const mention = before.match(/(?:^|\s)@([\p{L}\p{N}._/-]*)$/u);
if (mention) {
this._showMentionMenu(mention[1]);
return;
@@ -758,10 +773,12 @@ class BooksLM {
async _showCommandMenu(query) {
if (!this._panel) return;
const seq = ++this._menuSeq;
const skills = await this._ensureSkills();
const q = (query || '').toLowerCase();
if (seq !== this._menuSeq) return; // a newer input superseded this render
const q = normalizeSearch(query);
const matches = skills.filter((s) =>
!q || s.id.includes(q) || (s.label || '').toLowerCase().includes(q));
!q || normalizeSearch(s.id).includes(q) || normalizeSearch(s.label).includes(q));
this._renderMenu('command', matches, (skill) => this._selectCommand(skill));
}
@@ -771,18 +788,18 @@ class BooksLM {
if (skill.type === 'admin') {
this._runAdminCommand(skill.id);
if (textarea) {
textarea.value = textarea.value.replace(/(^|\s)\/[a-z0-9-]*$/i, '$1').trim();
textarea.value = textarea.value.replace(/(^|\s)\/[\p{L}\p{N}_-]*$/u, '$1').trim();
}
return;
}
if (skill.special === 'create_skill') {
if (textarea) textarea.value = textarea.value.replace(/(^|\s)\/[a-z0-9-]*$/i, '$1').trim();
if (textarea) textarea.value = textarea.value.replace(/(^|\s)\/[\p{L}\p{N}_-]*$/u, '$1').trim();
this._promptCreateSkill();
return;
}
this._activeSkill = skill.id;
if (textarea) {
textarea.value = textarea.value.replace(/(^|\s)\/[a-z0-9-]*$/i, '$1').trim();
textarea.value = textarea.value.replace(/(^|\s)\/[\p{L}\p{N}_-]*$/u, '$1').trim();
textarea.focus();
}
this._renderAttachments();
@@ -790,16 +807,19 @@ class BooksLM {
async _showMentionMenu(query) {
if (!this._panel) return;
const seq = ++this._menuSeq;
const vault = this._resolveVault();
if (!vault) {
if (!vault && !query) {
// Nothing to list without a vault; a typed query can still search all.
this._hideMenus();
return;
}
try {
let results = [];
if (query) {
const searchVault = vault || 'all';
const data = await api(
`/api/tree-search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vault)}`
`/api/tree-search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(searchVault)}`
);
results = (data.results || []).slice(0, 20);
} else {
@@ -808,13 +828,16 @@ class BooksLM {
);
results = (data.files || []).slice(0, 20).map((f) => ({ path: f.path, type: 'file' }));
}
const items = results.map((r) => ({
id: r.path || r,
label: r.path || r,
type: r.type || (this._classifyPath(r.path || r) === 'dir' ? 'dir' : 'file'),
}));
if (seq !== this._menuSeq) return; // superseded while the request ran
const items = results.map((r) => {
const path = r.path || r;
const rawType = r.type || (this._classifyPath(path) === 'dir' ? 'dir' : 'file');
const type = rawType === 'directory' ? 'dir' : rawType;
return { id: path, label: path, type };
});
this._renderMenu('mention', items, (item) => this._selectMention(item));
} catch (e) {
if (seq !== this._menuSeq) return;
console.warn('AI assistant: mention search failed', e);
this._hideMenus();
}
@@ -824,7 +847,7 @@ class BooksLM {
const textarea = this._panel && this._panel.querySelector('textarea');
this._hideMenus();
if (textarea) {
textarea.value = textarea.value.replace(/(^|\s)@[\w./-]*$/, '$1').trim();
textarea.value = textarea.value.replace(/(^|\s)@[\p{L}\p{N}._/-]*$/u, '$1').trim();
textarea.focus();
}
const path = item.id;
@@ -864,6 +887,8 @@ class BooksLM {
el.type = 'button';
el.className = 'bookslm-menu-item' + (index === 0 ? ' active' : '');
el.dataset.index = String(index);
el.setAttribute('role', 'option');
el.setAttribute('aria-selected', index === 0 ? 'true' : 'false');
const icon = document.createElement('span');
icon.className = 'bookslm-menu-icon';
icon.textContent = item.icon || (item.type === 'dir' ? '📁' : item.type === 'admin' ? '⚙️' : '📄');
@@ -901,10 +926,19 @@ class BooksLM {
if (!this._panel || !this._menuKind) return;
const menu = this._panel.querySelector(`.bookslm-${this._menuKind}-menu`);
if (!menu) return;
this._menuIndex = index;
menu.querySelectorAll('.bookslm-menu-item').forEach((el, i) => {
el.classList.toggle('active', i === index);
const options = menu.querySelectorAll('.bookslm-menu-item');
if (!options.length) return;
const next = ((index % options.length) + options.length) % options.length;
this._menuIndex = next;
options.forEach((el, i) => {
const on = i === next;
el.classList.toggle('active', on);
el.setAttribute('aria-selected', on ? 'true' : 'false');
});
const active = options[next];
if (active && typeof active.scrollIntoView === 'function') {
active.scrollIntoView({ block: 'nearest' });
}
}
_handleMenuKey(e) {
@@ -938,6 +972,8 @@ class BooksLM {
_hideMenus() {
if (!this._panel) return;
// Invalidate any in-flight async menu render.
this._menuSeq++;
this._panel.querySelectorAll('.bookslm-command-menu, .bookslm-mention-menu').forEach((m) => {
m.classList.add('hidden');
});
+3 -1
View File
@@ -128,6 +128,8 @@
"ai.model_offline": "offline",
"ai.model_load_error": "Load error",
"ai.model_settings": "Provider & model",
"ai.model_search": "Search a model…",
"ai.model_info": "Model capabilities",
"ai.add_context": "Add context",
"ai.attach_image": "Attach an image",
"ai.cap_audio_speech": "Audio Speech",
@@ -1269,7 +1271,7 @@
"help.assistant_resize": "The left edge of the panel is resizable; the width is remembered.",
"help.assistant_at": "Type @ to attach a file or directory to the context, or to attach an image from a directory.",
"help.assistant_slash": "Type / to run a skill (research, summary, correction, plan…) or an admin command (/help, /providers, /model, /keys).",
"help.assistant_images": "Paste an image into the composer (or attach one) to analyse it with a vision-capable model; the selected model's capabilities are displayed.",
"help.assistant_images": "Paste an image into the composer (or attach one) to analyse it with a vision-capable model. The ⓘ button shows the selected model's capabilities, and the model list is filterable by search.",
"help.toolbar_section": "AI toolbar",
"help.tree_section": "Vault tree",
"help.use_cases": "Use cases",
+3 -1
View File
@@ -128,6 +128,8 @@
"ai.model_offline": "hors ligne",
"ai.model_load_error": "Erreur de chargement",
"ai.model_settings": "Fournisseur & modèle",
"ai.model_search": "Rechercher un modèle…",
"ai.model_info": "Capacités du modèle",
"ai.add_context": "Ajouter un contexte",
"ai.attach_image": "Joindre une image",
"ai.cap_audio_speech": "Audio Speech",
@@ -1269,7 +1271,7 @@
"help.assistant_resize": "Le bord gauche du panneau est redimensionnable ; la largeur est mémorisée.",
"help.assistant_at": "Tapez @ pour joindre un fichier ou un répertoire au contexte, ou pour attacher une image d'un répertoire.",
"help.assistant_slash": "Tapez / pour lancer un skill (recherche, résumé, correction, plan…) ou une commande admin (/help, /providers, /model, /keys).",
"help.assistant_images": "Collez une image dans la zone de saisie (ou joignez-la) pour l'analyser avec un modèle compatible vision ; les capacités du modèle sélectionné sont affichées.",
"help.assistant_images": "Collez une image dans la zone de saisie (ou joignez-la) pour l'analyser avec un modèle compatible vision. Le bouton ⓘ affiche les capacités du modèle sélectionné, et la liste des modèles est filtrable par recherche.",
"help.toolbar_section": "Barre d'outils AI",
"help.tree_section": "Arborescence des vaults",
"help.use_cases": "Cas d'usage",
+41 -2
View File
@@ -9362,8 +9362,47 @@ body.bookslm-resizing { cursor: ew-resize; user-select: none; }
.bookslm-modal-actions button { padding: 7px 14px; border-radius: 8px; border: 1px solid var(--border);
background: var(--surface); color: var(--text-primary); cursor: pointer; font-size: 13px; }
.bookslm-modal-actions button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
/* Model capability checklist in the picker. */
.ai-picker-caps-host { flex-basis: 100%; }
/* ── Provider/model picker (compact, shared by assistant + editor) ── */
.ai-picker { display: inline-flex; align-items: center; gap: 6px;
font-size: 0.7rem; color: var(--text-secondary); flex-shrink: 0; position: relative; }
.ai-picker-label { white-space: nowrap; }
.ai-picker select { font-size: 0.7rem; background: var(--surface); color: var(--text-primary);
border: 1px solid var(--border); border-radius: 4px; padding: 2px 4px; cursor: pointer; }
/* Searchable model combobox. */
.ai-picker-model-combo { position: relative; display: inline-flex; }
.ai-picker-model-trigger { display: inline-flex; align-items: center; gap: 4px; max-width: 220px;
font-size: 0.7rem; background: var(--surface); color: var(--text-primary);
border: 1px solid var(--border); border-radius: 4px; padding: 3px 6px; cursor: pointer; }
.ai-picker-model-trigger:hover { border-color: var(--accent); }
.ai-picker-model-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ai-picker-model-chevron { opacity: 0.6; font-size: 0.6rem; }
.ai-picker-model-pop { 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; }
.ai-picker-model-pop.hidden { display: none; }
.ai-picker-model-search { width: 100%; box-sizing: border-box; font-size: 0.72rem;
background: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border);
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-option { text-align: left; font-size: 0.72rem; padding: 5px 8px; border-radius: 5px;
background: none; border: none; color: var(--text-primary); 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-empty { font-size: 0.7rem; color: var(--text-secondary); padding: 6px 8px; text-align: center; }
/* Capability info button (ⓘ) + informational bubble. */
.ai-picker-caps-host { position: relative; display: inline-flex; }
.ai-picker-info { background: none; border: none; cursor: pointer; color: var(--text-secondary);
font-size: 0.85rem; line-height: 1; padding: 2px 4px; border-radius: 4px; }
.ai-picker-info:hover, .ai-picker-info:focus { color: var(--accent); }
.ai-picker-info.hidden { display: none; }
.ai-picker-caps-pop { 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; }
.ai-picker-caps-pop.hidden { display: none; }
.ai-picker-caps-title { font-size: 0.7rem; font-weight: 600; color: var(--text-secondary);
text-transform: uppercase; letter-spacing: .04em; margin-bottom: 4px; }
.ai-picker-caps { display: flex; flex-wrap: wrap; gap: 4px 10px; margin-top: 4px; font-size: 10px;
color: var(--text-secondary); }
.ai-cap-item { white-space: nowrap; opacity: 0.65; }
+171
View File
@@ -788,6 +788,177 @@ async function main() {
b._panel.remove();
});
// ── 38. Accent-insensitive `/` command filtering (BUG-007) ──
await test("slash command filter is accent-insensitive", async () => {
localStorage.clear();
globalThis.fetch = async (url) => {
if (String(url).includes("/api/ai/skills")) {
return {
ok: true, status: 200,
json: async () => ({
skills: [{ id: "resume", label: "Résumé", icon: "📄", type: "skill", description: "d" }],
commands: [],
}),
};
}
return { ok: true, status: 200, json: async () => ({}) };
};
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
const ta = b._panel.querySelector("textarea");
ta.value = "/resume";
ta.selectionStart = ta.selectionEnd = 7;
b._onComposerInput();
await sleep(20);
let menu = b._panel.querySelector(".bookslm-command-menu");
assert.ok(!menu.classList.contains("hidden"), "ascii query keeps the menu open");
assert.equal(menu.querySelectorAll(".bookslm-menu-item").length, 1);
ta.value = "/résumé";
ta.selectionStart = ta.selectionEnd = 7;
b._onComposerInput();
await sleep(20);
menu = b._panel.querySelector(".bookslm-command-menu");
assert.ok(!menu.classList.contains("hidden"), "accented query keeps the menu open");
assert.equal(menu.querySelectorAll(".bookslm-menu-item").length, 1);
b._panel.remove();
localStorage.clear();
});
// ── 39. Accented `@` mentions + all-vault fallback (BUG-008) ──
await test("mention menu accepts accents and searches all vaults without a vault", async () => {
localStorage.clear();
let lastUrl = "";
globalThis.fetch = async (url) => {
lastUrl = String(url);
if (lastUrl.includes("/api/tree-search")) {
return {
ok: true, status: 200,
json: async () => ({ results: [{ path: "Café/note.md", type: "file" }] }),
};
}
return { ok: true, status: 200, json: async () => ({}) };
};
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
b._vault = null;
const ta = b._panel.querySelector("textarea");
ta.value = "@café";
ta.selectionStart = ta.selectionEnd = 5;
b._onComposerInput();
await sleep(20);
const menu = b._panel.querySelector(".bookslm-mention-menu");
assert.ok(!menu.classList.contains("hidden"), "accented mention keeps the menu open");
assert.ok(lastUrl.includes("vault=all"), "no vault -> searches all vaults");
assert.equal(menu.querySelectorAll(".bookslm-menu-item").length, 1);
b._panel.remove();
localStorage.clear();
});
// ── 40. Keyboard navigation in the command menu (BUG-007) ──
await test("arrow keys move the active item in the command menu", async () => {
localStorage.clear();
globalThis.fetch = async (url) => {
if (String(url).includes("/api/ai/skills")) {
return {
ok: true, status: 200,
json: async () => ({
skills: [
{ id: "a", label: "Alpha", type: "skill", description: "d" },
{ id: "b", label: "Beta", type: "skill", description: "d" },
{ id: "c", label: "Gamma", type: "skill", description: "d" },
],
commands: [],
}),
};
}
return { ok: true, status: 200, json: async () => ({}) };
};
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
const ta = b._panel.querySelector("textarea");
ta.value = "/";
ta.selectionStart = ta.selectionEnd = 1;
b._onComposerInput();
await sleep(20);
const items = () => Array.from(b._panel.querySelectorAll(".bookslm-command-menu .bookslm-menu-item"));
assert.equal(items().length, 3);
assert.ok(items()[0].classList.contains("active"), "first item active by default");
const down = new w.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true });
ta.dispatchEvent(down);
assert.ok(down.defaultPrevented, "ArrowDown handled by the menu");
assert.ok(items()[1].classList.contains("active"), "second item active after ArrowDown");
ta.dispatchEvent(new w.KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true, cancelable: true }));
assert.ok(items()[0].classList.contains("active"), "back to first after ArrowUp");
b._panel.remove();
localStorage.clear();
});
// ── 41. Compact picker: model search + capability info bubble (#82) ──
await test("picker model search filters and info reveals capabilities", async () => {
localStorage.clear();
globalThis.fetch = async (url) => {
const u = String(url);
if (u.includes("/api/ai/status")) {
return { ok: true, status: 200, json: async () => ({ configured: true, providers: { deepseek: { available: true } } }) };
}
if (u.includes("/api/config/ai-models")) {
return { ok: true, status: 200, json: async () => ({ models: ["deepseek-chat", "deepseek-reasoner", "vision-model"], source: "api" }) };
}
if (u.includes("/api/ai/model-capabilities")) {
return {
ok: true, status: 200,
json: async () => ({
capabilities: { chat: true, embeddings: false, rerank: false, images: false, video: false, audio_speech: false, audio_transcription: false, vision: false },
}),
};
}
return { ok: true, status: 200, json: async () => ({}) };
};
localStorage.setItem("obsigate_ai_picker", JSON.stringify({ provider: "deepseek" }));
const picker = await ai.buildAIPickerUI();
assert.ok(picker, "picker built");
document.body.appendChild(picker);
await sleep(30);
const hidden = picker.querySelector(".ai-picker-model");
assert.ok(hidden && hidden.hidden, "hidden native select kept for admin commands");
assert.ok(hidden.querySelectorAll("option").length >= 4, "native select holds default + models");
picker.querySelector(".ai-picker-model-trigger").click();
const pop = picker.querySelector(".ai-picker-model-pop");
assert.ok(!pop.classList.contains("hidden"), "model popover opens");
assert.equal(pop.querySelectorAll(".ai-picker-model-option").length, 4, "default + 3 models");
const search = picker.querySelector(".ai-picker-model-search");
search.value = "reason";
search.dispatchEvent(new w.Event("input", { bubbles: true }));
const filtered = pop.querySelectorAll(".ai-picker-model-option");
assert.equal(filtered.length, 1, "search filters the list");
assert.equal(filtered[0].dataset.value, "deepseek-reasoner");
filtered[0].dispatchEvent(new w.Event("mousedown", { bubbles: true }));
assert.equal(hidden.value, "deepseek-reasoner", "selection updates the native select");
const stored = JSON.parse(localStorage.getItem("obsigate_ai_picker"));
assert.equal(stored.model, "deepseek-reasoner");
await sleep(20);
const info = picker.querySelector(".ai-picker-info");
assert.ok(!info.classList.contains("hidden"), "info button visible once capabilities loaded");
info.click();
const capsPop = picker.querySelector(".ai-picker-caps-pop");
assert.ok(!capsPop.classList.contains("hidden"), "capability bubble opens");
assert.equal(capsPop.querySelectorAll(".ai-cap-item").length, 8, "all capability flags listed in the bubble");
picker.remove();
localStorage.clear();
});
// ── Summary ──
console.log(`\n${passCount}/${testCount} tests passed`);
if (passCount !== testCount) {
+50
View File
@@ -747,6 +747,56 @@ class TestBooksLMChatModes:
assert resp.status_code in (200, 500), f"unexpected {resp.status_code}: {resp.text[:200]}"
assert resp.status_code != 422
def test_general_chat_includes_adhoc_files(self, bookslm_client, monkeypatch):
"""`@` ad-hoc files must be read even in General mode (BUG-008)."""
import backend.bookslm_routes as routes
captured = {}
async def fake_stream(messages, **kwargs):
captured["messages"] = messages
yield "ok"
monkeypatch.setattr(routes, "stream_completion", fake_stream)
monkeypatch.setattr(routes, "_resolve_provider_name", lambda requested: "deepseek")
token, _ = _login_bookslm(bookslm_client)
resp = bookslm_client.post(
"/api/ai/bookslm/chat",
json={
"mode": "general",
"vault": "TestVault",
"extra_files": ["notes/note1.md"],
"message": "Résume",
},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200, resp.text[:200]
system_prompt = captured["messages"][0]["content"]
assert "First note content" in system_prompt, "ad-hoc file content missing from prompt"
def test_general_chat_without_adhoc_ignores_vault(self, bookslm_client, monkeypatch):
"""General mode without ad-hoc context stays vault-independent."""
import backend.bookslm_routes as routes
captured = {}
async def fake_stream(messages, **kwargs):
captured["messages"] = messages
yield "ok"
monkeypatch.setattr(routes, "stream_completion", fake_stream)
monkeypatch.setattr(routes, "_resolve_provider_name", lambda requested: "deepseek")
token, _ = _login_bookslm(bookslm_client)
resp = bookslm_client.post(
"/api/ai/bookslm/chat",
json={"mode": "general", "vault": "UnknownVault", "message": "Bonjour"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200, resp.text[:200]
assert captured["messages"][0]["content"]
# ── Tool-calling agent endpoint ───────────────────────────────────────