diff --git a/backend/ai_routes.py b/backend/ai_routes.py index f9033dd..2225568 100644 --- a/backend/ai_routes.py +++ b/backend/ai_routes.py @@ -101,7 +101,8 @@ class AIRequest(BaseModel): instruction: str | None = Field(None, description="Custom instruction for rewrite") target_lang: str | None = Field(None, description="Target language for translation") tone: str | None = Field(None, description="Target tone (professional, casual, etc.)") - provider: str | None = Field(None, description="AI provider override") + provider: str | None = Field(None, description="AI provider override (e.g. 'deepseek', 'nvidia')") + model: str | None = Field(None, description="Model name override for this request") class AIResponse(BaseModel): @@ -111,6 +112,12 @@ class AIResponse(BaseModel): async def _handle(action, request: AIRequest): """Wrapper with error handling.""" + from backend.ai import PROVIDERS + # Apply per-request model override (saved/restored around the call) + original_model = None + if request.model and request.provider and request.provider in PROVIDERS: + original_model = PROVIDERS[request.provider].get("model") + PROVIDERS[request.provider]["model"] = request.model try: result = await action(request.text, request.provider) return AIResponse(result=result, provider=request.provider or "default") @@ -119,6 +126,9 @@ async def _handle(action, request: AIRequest): except Exception as e: logger.error(f"AI error: {e}") raise HTTPException(status_code=500, detail=f"AI service error: {e!s}") + finally: + if original_model is not None and request.provider in PROVIDERS: + PROVIDERS[request.provider]["model"] = original_model @router.post("/improve", response_model=AIResponse) diff --git a/backend/bookslm_routes.py b/backend/bookslm_routes.py index 347e0bd..48699a7 100644 --- a/backend/bookslm_routes.py +++ b/backend/bookslm_routes.py @@ -32,6 +32,15 @@ class BooksLMChatRequest(BaseModel): default_factory=list, description="Previous conversation turns [{role, content}]", ) + provider: str | None = Field( + default=None, + description="AI provider override (e.g. 'deepseek', 'openrouter', 'gemini', 'nvidia', 'xiaomi', 'mistral', 'qwencloud'). " + "If not set, uses DEFAULT_PROVIDER.", + ) + model: str | None = Field( + default=None, + description="Model name to use for this request. If not set, uses the provider's default model.", + ) # ── Endpoints ── @@ -107,27 +116,44 @@ async def api_bookslm_chat( async def generate_sse(): try: - cfg_name = DEFAULT_PROVIDER - # Check if default provider is available - if cfg_name != "gemini" and cfg_name in PROVIDERS and not PROVIDERS[cfg_name].get("api_key"): - # Find first available provider + # Resolve provider: explicit override wins, else default. + # Fall back to first available if the requested one isn't configured. + cfg_name = (req.provider or DEFAULT_PROVIDER).lower() + if cfg_name not in PROVIDERS or not PROVIDERS[cfg_name].get("api_key"): + # Try next available provider for pname, pcfg in PROVIDERS.items(): if pcfg.get("api_key") and pname != "gemini": cfg_name = pname break + else: + # No provider available at all + err = "Aucun fournisseur AI configuré (clés API manquantes)" + error_data = json.dumps({"error": err}, ensure_ascii=False) + yield f"event: error\ndata: {error_data}\n\n" + return - if cfg_name == "gemini" and PROVIDERS.get("gemini", {}).get("api_key"): - response = await _call_gemini(user_prompt, system_prompt, temperature=0.3, max_tokens=4096) - else: - response = await _call_deepseek_openrouter( - user_prompt, system_prompt, - provider=cfg_name if cfg_name in PROVIDERS else None, - temperature=0.3, - max_tokens=4096, - ) + # Optional per-request model override + original_model = None + if req.model and cfg_name in PROVIDERS: + original_model = PROVIDERS[cfg_name].get("model") + PROVIDERS[cfg_name]["model"] = req.model + try: + if cfg_name == "gemini": + response = await _call_gemini(user_prompt, system_prompt, temperature=0.3, max_tokens=4096) + else: + response = await _call_deepseek_openrouter( + user_prompt, system_prompt, + provider=cfg_name, + temperature=0.3, + max_tokens=4096, + ) + finally: + # Restore the original model so other calls aren't affected + if original_model is not None and cfg_name in PROVIDERS: + PROVIDERS[cfg_name]["model"] = original_model # Send the full response as a single SSE event - data = json.dumps({"token": response}, ensure_ascii=False) + data = json.dumps({"token": response, "provider": cfg_name, "model": req.model or PROVIDERS.get(cfg_name, {}).get("model", "")}, ensure_ascii=False) yield f"event: message\ndata: {data}\n\n" yield "event: done\ndata: {}\n\n" except Exception as e: diff --git a/backend/main.py b/backend/main.py index 40fad9b..3ae057f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4058,19 +4058,30 @@ async def api_test_ai_keys(current_user=Depends(require_admin)): @app.get("/api/config/ai-models") async def api_list_ai_models(provider: str = Query(...), current_user=Depends(require_admin)): - """List available models for a given AI provider.""" + """List available models for a given AI provider. + + Strategy: + 1. Try the provider's public models endpoint (OpenAI-compatible /v1/models or Gemini). + 2. If the network call fails (timeout, 4xx, 5xx, DNS, etc.), fall back to a + curated static list of known-good models for that provider. + 3. Always return a non-empty list when the provider is known, so the UI + dropdown is never empty. + """ provider = provider.lower() all_providers = ("deepseek", "openrouter", "gemini", "nvidia", "qwencloud", "xiaomi", "mistral") if provider not in all_providers: - return {"models": [], "error": f"Unknown provider: {provider}"} + return {"models": [], "error": f"Unknown provider: {provider}", "source": "validation"} key_name = f"{provider.upper()}_API_KEY" key = get_ai_key(key_name) if not key: - return {"models": [], "error": "API key not configured"} + # No key configured — return curated fallback list so the UI can + # still show what WOULD be available once a key is set. + return {"models": _FALLBACK_MODELS.get(provider, []), "source": "fallback", + "note": "API key not configured — showing default model list"} - # Build URL and request + # Build URL if provider == "gemini": url = f"https://generativelanguage.googleapis.com/v1beta/models?key={key}" elif provider == "deepseek": @@ -4082,6 +4093,8 @@ async def api_list_ai_models(provider: str = Query(...), current_user=Depends(re elif provider == "qwencloud": url = "https://dashscope.aliyuncs.com/compatible-mode/v1/models" elif provider == "xiaomi": + # Xiaomi's public /v1/models endpoint is not stable — fetch if reachable, + # otherwise fall back to a curated list of mimo models. url = "https://api.xiaomi.com/v1/models" elif provider == "mistral": url = "https://api.mistral.ai/v1/models" @@ -4096,13 +4109,86 @@ async def api_list_ai_models(provider: str = Query(...), current_user=Depends(re data = _json.loads(resp.read().decode()) if provider == "gemini": - models = [m.get("name", "") for m in data.get("models", [])] + models = [m.get("name", "") for m in data.get("models", []) if m.get("name")] + # Gemini returns names like "models/gemini-1.5-flash" — strip prefix + models = [m.replace("models/", "") for m in models] else: - models = [m.get("id", "") for m in data.get("data", [])] + models = [m.get("id", "") for m in data.get("data", []) if m.get("id")] - return {"models": models} + if models: + # Prepend the configured default if not already present + default = PROVIDERS.get(provider, {}).get("model") + if default and default not in models: + models = [default] + models + return {"models": models, "source": "live", "count": len(models)} + # Empty list from API — fall through to fallback + raise ValueError("empty model list from provider API") except Exception as e: - return {"models": [], "error": str(e)} + # Network error, auth error, parsing error — use curated fallback + fallback = _FALLBACK_MODELS.get(provider, []) + return {"models": fallback, "source": "fallback", "error": str(e)[:200], + "note": "Could not reach provider API — showing default model list"} + + +# ── Curated fallback model lists ────────────────────────────────────────── +# Used when the provider API is unreachable or returns empty. +# Keep these short and focused on models known to work with the +# OpenAI-compatible chat completions interface (or Gemini's generateContent). +_FALLBACK_MODELS: dict[str, list[str]] = { + "deepseek": [ + "deepseek-chat", + "deepseek-reasoner", + ], + "openrouter": [ + "openai/gpt-4o-mini", + "openai/gpt-4o", + "anthropic/claude-3.5-sonnet", + "anthropic/claude-3-haiku", + "google/gemini-2.0-flash-exp:free", + "meta-llama/llama-3.1-70b-instruct", + "meta-llama/llama-3.1-8b-instruct:free", + "mistralai/mistral-large-latest", + ], + "gemini": [ + "gemini-2.0-flash", + "gemini-2.0-flash-exp", + "gemini-1.5-pro", + "gemini-1.5-flash", + "gemini-1.5-flash-8b", + ], + "nvidia": [ + "meta/llama-3.1-405b-instruct", + "meta/llama-3.1-70b-instruct", + "meta/llama-3.1-8b-instruct", + "mistralai/mistral-large", + "google/gemma-2-27b-it", + "nvidia/llama-3.1-nemotron-70b-instruct", + ], + "qwencloud": [ + "qwen-max", + "qwen-plus", + "qwen-turbo", + "qwen-long", + "qwen-vl-max", + "qwen-vl-plus", + ], + "xiaomi": [ + # Xiaomi MiMo models — the public /v1/models endpoint is unreliable, + # so we ship a known-good list as fallback. + "mimo-v2-pro", + "mimo-v2-flash", + "mimo-v2-vl", + "mimo-v2-tts", + ], + "mistral": [ + "mistral-large-latest", + "mistral-medium-latest", + "mistral-small-latest", + "open-mistral-7b", + "open-mixtral-8x7b", + "codestral-latest", + ], +} # --------------------------------------------------------------------------- diff --git a/frontend/js/admin.js b/frontend/js/admin.js index 21941ab..b29c9d0 100644 --- a/frontend/js/admin.js +++ b/frontend/js/admin.js @@ -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 { diff --git a/frontend/js/ai.js b/frontend/js/ai.js index 2fe9d62..9db5955 100644 --- a/frontend/js/ai.js +++ b/frontend/js/ai.js @@ -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, +}; diff --git a/frontend/js/bookslm.js b/frontend/js/bookslm.js index 774a3d7..3196888 100644 --- a/frontend/js/bookslm.js +++ b/frontend/js/bookslm.js @@ -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 {