fix(admin,ai): redirect admin.html + modèles fallback + picker provider/modèle par requête
CI / lint (push) Successful in 41s
CI / security (push) Successful in 28s
CI / test (push) Successful in 48s
CI / build (push) Successful in 22s
CI / e2e (push) Successful in 6m5s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s

Trois bugs corrigés + une amélioration demandée :

1. **fix(admin): /admin.html redirigeait toujours vers /**
   - admin.js _gateAdmin() lisait /api/auth/status qui ne contient PAS le rôle user
   - Remplacé par /api/auth/me (retourne username, role, vaults)
   - Le code distingue maintenant le cas 'auth désactivé' (admin anonyme) du
     cas 'auth requise non admin' (affiche écran forbidden)

2. **fix(ai): les modèles Nvidia/Xiaomi ne se chargeaient pas dans les dropdowns**
   - Xiaomi : l'endpoint public /v1/models est instable, échec réseau fréquent
   - Toutes les erreurs réseau/d'API renvoyaient models=[] → dropdown vide
   - Ajout d'un fallback curé : _FALLBACK_MODELS dict avec 4-8 modèles populaires
     par provider, TOUJOURS retourné si la clé manque OU si l'API distante échoue
   - Le frontend voit désormais 'fallback' vs 'live' comme hint pour l'utilisateur
   - Gemini parsing : strip du préfixe 'models/' retourné par l'API Gemini
   - 7 nouveaux tests pytest pour _FALLBACK_MODELS + endpoint /api/config/ai-models

3. **feat(ai): picker provider/model dans la toolbar AI + BooksLM**
   - Plusieurs providers peuvent maintenant être activés simultanément
   - Sélection provider+model par section (Forge, BooksLM, etc.) via dropdown
   - État persisté en localStorage (le choix suit l'utilisateur entre sections)
   - Chaque appel AI passe maintenant {provider, model} au backend
   - ai.js : aiAction() lit le picker et l'injecte dans le body
   - bookslm.js : envoie provider+model à /api/ai/bookslm/chat
   - ai_routes.py + bookslm_routes.py : AIRequest et BooksLMChatRequest
     acceptent provider+model, avec save/restore du modèle original
     pour ne pas affecter les autres requêtes concurrentes
   - 7 nouvelles clés i18n (ai.provider, ai.model, ai.model_loading, etc.)
   - 1 nouveau test bookslm vérifie que le schema accepte provider+model
   - validate-imports.mjs : fix faux positif sur 'export { X as Y }'

Vérifié :
- pytest : 502 passed, 5 skipped (494 baseline + 7 AI models + 1 BooksLM)
- frontend unit : 7 passed
- validate-imports : 30 modules / 204 exports / 0 erreur
- pane-manager JSDOM : 9/9
- ruff check backend/ : All checks passed
This commit is contained in:
2026-09-07 12:00:05 -04:00
parent 46be24f6a3
commit 7ff9b854b6
11 changed files with 642 additions and 36 deletions
+30 -9
View File
@@ -364,30 +364,51 @@ function _wireAuditFilters() {
/**
* Verify the current session and admin role.
* Uses /api/auth/me (which returns the current user) and falls back to
* the cached user in sessionStorage if the request fails.
* Redirects to / if not authenticated or not admin.
* Returns the user object on success, null otherwise.
*/
async function _gateAdmin() {
const container = document.getElementById("admin-main");
const forbidden = document.getElementById("admin-forbidden");
// First check whether auth is even enabled (public endpoint).
let authEnabled = false;
try {
const res = await fetch("/api/auth/status", { credentials: "include" });
if (!res.ok) throw new Error("HTTP " + res.status);
const status = await res.json();
if (!status.auth_enabled) {
// Auth disabled — server is wide open, treat current visitor as allowed.
return { username: "anonymous", role: "admin" };
const statusRes = await fetch("/api/auth/status", { credentials: "include" });
if (statusRes.ok) {
const status = await statusRes.json();
authEnabled = !!status.auth_enabled;
}
if (!status.authenticated) {
} catch { /* network error — fall through */ }
if (!authEnabled) {
// Server is wide open, treat current visitor as allowed.
return { username: "anonymous", role: "admin" };
}
// Auth is enabled — try to load the current user.
try {
const meRes = await fetch("/api/auth/me", {
credentials: "include",
headers: getAuthHeaders(),
});
if (meRes.status === 401 || meRes.status === 403) {
window.location.href = "/";
return null;
}
if (status.user?.role !== "admin") {
if (!meRes.ok) throw new Error("HTTP " + meRes.status);
const user = await meRes.json();
// Cache for later fallback
try {
sessionStorage.setItem("obsigate_user", JSON.stringify(user));
} catch { /* */ }
if (user.role !== "admin") {
if (container) container.style.display = "none";
if (forbidden) forbidden.style.display = "";
return null;
}
return status.user;
return user;
} catch (err) {
// Network error — try to use cached sessionStorage user as fallback
try {
+182 -1
View File
@@ -13,7 +13,14 @@ import { t } from './i18n.js';
// ── API call helper ──
async function aiAction(endpoint, text, extra = {}) {
const body = { text, ...extra };
// Inject current provider + model from the per-section picker.
const pickerState = _readPicker();
const body = {
text,
...extra,
...(pickerState.provider ? { provider: pickerState.provider } : {}),
...(pickerState.model ? { model: pickerState.model } : {}),
};
const data = await api(`/api/ai/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -22,6 +29,163 @@ async function aiAction(endpoint, text, extra = {}) {
return data.result;
}
// ── Per-section provider/model picker ───────────────────────────────────────
// State is stored in localStorage so the user choice persists across sections.
const PICKER_STORAGE_KEY = 'obsigate_ai_picker';
function _readPicker() {
try {
return JSON.parse(localStorage.getItem(PICKER_STORAGE_KEY) || '{}');
} catch { return {}; }
}
function _writePicker(state) {
try {
localStorage.setItem(PICKER_STORAGE_KEY, JSON.stringify(state));
} catch { /* */ }
}
/**
* 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.
*/
async function _buildPickerUI() {
const pickerState = _readPicker();
// Fetch configured providers
let availableProviders = {};
try {
const status = await api('/api/ai/status');
availableProviders = status.providers || {};
} catch { /* */ }
const providerNames = Object.keys(availableProviders).filter(
(p) => availableProviders[p]?.available
);
if (!providerNames.length) return null;
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-color)',
fontSize: '0.7rem',
color: 'var(--text-muted)',
});
// Provider select
const providerLabel = document.createElement('span');
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(--bg-tertiary, transparent)',
color: 'var(--text-primary)',
border: '1px solid var(--border-color)',
borderRadius: '4px',
padding: '2px 4px',
cursor: 'pointer',
});
const defaultOpt = document.createElement('option');
defaultOpt.value = '';
defaultOpt.textContent = t('ai.provider_default');
providerSelect.appendChild(defaultOpt);
providerNames.forEach((p) => {
const opt = document.createElement('option');
opt.value = p;
opt.textContent = p;
if (p === pickerState.provider) opt.selected = true;
providerSelect.appendChild(opt);
});
// Model select (sibling of provider)
const modelSelect = document.createElement('select');
modelSelect.className = 'ai-picker-model';
Object.assign(modelSelect.style, {
fontSize: '0.7rem',
background: 'var(--bg-tertiary, transparent)',
color: 'var(--text-primary)',
border: '1px solid var(--border-color)',
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);
async function _loadModels(provider) {
modelSelect.innerHTML = '';
const ph = document.createElement('option');
ph.value = '';
ph.textContent = t('ai.model_loading');
modelSelect.appendChild(ph);
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')})` : '');
modelSelect.appendChild(def);
(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);
});
} catch (err) {
modelSelect.innerHTML = '';
const opt = document.createElement('option');
opt.value = '';
opt.textContent = t('ai.model_load_error');
modelSelect.appendChild(opt);
}
}
providerSelect.addEventListener('change', () => {
pickerState.provider = providerSelect.value || null;
// Reset model when provider changes
pickerState.model = null;
_writePicker(pickerState);
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);
}
});
modelSelect.addEventListener('change', () => {
pickerState.model = modelSelect.value || null;
_writePicker(pickerState);
});
// Load models for the initial provider selection
if (pickerState.provider && providerNames.includes(pickerState.provider)) {
_loadModels(pickerState.provider);
}
wrap.appendChild(providerSelect);
wrap.appendChild(modelSelect);
return wrap;
}
// ── Get selected text from CodeMirror ──
function getSelection(editorView) {
if (!editorView) return '';
@@ -357,6 +521,13 @@ export async function createAIToolbar(container, getEditorView) {
toolbar.appendChild(rewriteBtn);
toolbar.appendChild(toolboxBtn);
// Append the per-section provider/model picker on the right.
const picker = await _buildPickerUI();
if (picker) {
toolbar.appendChild(createSeparator());
toolbar.appendChild(picker);
}
container.insertBefore(toolbar, container.firstChild);
// ── Action helper ──
@@ -455,3 +626,13 @@ function createSeparator() {
sep.style.cssText = 'width:1px;height:16px;background:var(--border-color);margin:0 2px';
return sep;
}
// ── Public exports ────────────────────────────────────────────────────────
// Other modules (e.g. BooksLM) can import the picker helpers to render the
// same per-section provider/model picker in their own toolbar.
export {
_readPicker,
_writePicker,
PICKER_STORAGE_KEY,
_buildPickerUI as buildAIPickerUI,
};
+20 -1
View File
@@ -1,5 +1,6 @@
// BooksLM — Directory-scoped AI chat panel (style NotebookLM)
import { t } from './i18n.js';
import { buildAIPickerUI } from './ai.js';
class BooksLM {
constructor() {
@@ -118,6 +119,7 @@ class BooksLM {
<div class="bookslm-header">
<span>📚</span>
<span class="bookslm-title"></span>
<span class="bookslm-picker-host" style="margin-left:auto"></span>
<button class="bookslm-btn-new" title="${t('bookslm.new_conversation')}">✨</button>
<button class="bookslm-btn-export" title="${t('bookslm.export')}">📥</button>
<button class="bookslm-btn-fullscreen" title="⛶">⛶</button>
@@ -132,6 +134,12 @@ class BooksLM {
</div>
`;
// Inject the provider/model picker asynchronously (depends on /api/ai/status)
const pickerHost = panel.querySelector('.bookslm-picker-host');
buildAIPickerUI().then((picker) => {
if (picker && pickerHost) pickerHost.replaceWith(picker);
}).catch(() => { /* ignore */ });
// Wire events
panel.querySelector('.bookslm-btn-close').addEventListener('click', () => this.close());
panel.querySelector('.bookslm-btn-new').addEventListener('click', () => this.newConversation());
@@ -288,6 +296,15 @@ class BooksLM {
this._abortCtrl = new AbortController();
try {
// Read provider/model from the picker (localStorage-backed)
let provider = null;
let model = null;
try {
const picker = JSON.parse(localStorage.getItem('obsigate_ai_picker') || '{}');
provider = picker.provider || null;
model = picker.model || null;
} catch { /* */ }
const resp = await fetch('/api/ai/bookslm/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -295,7 +312,9 @@ class BooksLM {
vault: this._vault,
directory: this._directory,
messages: this._messages.slice(0, -1), // exclude empty assistant
context_files: this._contextFiles.map(f => f.path || f)
context_files: this._contextFiles.map(f => f.path || f),
provider,
model,
}),
signal: this._abortCtrl.signal
});