From 7ea76273f2ac69301e60e312dcfaca6430e3ec40 Mon Sep 17 00:00:00 2001 From: 2alf Date: Tue, 5 May 2026 21:33:53 +0200 Subject: [PATCH] [feat] Added huggingface support --backend - app.py > Added helper functions for loading secrets safely in non-docker env(s) > HF connection via router api with a default option of a llama model. > Copied existing structure from OPENAI for HF implementation > Connection validation function for HF with a minimal test payload of 1 token. -- frontend - settings.html > Button to test connection and a string field for model name. -- documentation - readme > Added setup hints for HF setup - app.py > bumbed from 5.3.7 -> 5.3.8 --- .env.example | 3 + .gitignore | 8 ++ README.md | 3 + app.py | 215 ++++++++++++++++++++++++++++++++++++++-- templates/settings.html | 154 ++++++++++++++++++++++++++-- 5 files changed, 366 insertions(+), 17 deletions(-) create mode 100644 .gitignore diff --git a/.env.example b/.env.example index 560e089..6ad8b4d 100644 --- a/.env.example +++ b/.env.example @@ -20,3 +20,6 @@ PROXY_FIX_X_PREFIX=1 # OpenAI Key: OPENAI_API_KEY= + +# Hugging Face +HUGGINGFACE_API_KEY= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bfd602f --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.env +.venv/ +.venv-win/ +__pycache__/ +*.pyc +instance/secrets.json +/instance +*.sqlite3 diff --git a/README.md b/README.md index ef276f1..34bb2db 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,8 @@ services: - OLLAMA_MODEL=phi4:14b # Optional OpenAI support - OPENAI_API_KEY= + # Hugging Face + - HUGGINGFACE_API_KEY= volumes: - ./instance:/app/instance - ./logs:/app/logs @@ -159,6 +161,7 @@ Fallacy Finder is designed to work well in self-hosted environments. While we en - `SECRET_KEY` — Flask session secret (can be generated here) - `OPENAI_API_KEY` — optional, only needed if using OpenAI +- `HUGGINGFACE_API_KEY` - optional, only needed if using HF models **Extended Configuration Options**
Options for HTTPS and Reverse Proxy support can be found in the `.env.example` file. diff --git a/app.py b/app.py index 7ad5afc..2a67f46 100644 --- a/app.py +++ b/app.py @@ -37,10 +37,41 @@ from werkzeug.middleware.proxy_fix import ProxyFix BASE_DIR = Path(__file__).resolve().parent -APP_VERSION = str(os.getenv("APP_VERSION") or "0.5.3.7").strip() +APP_VERSION = str(os.getenv("APP_VERSION") or "0.5.3.8").strip() APP_AUTHOR_NAME = "Jacques Laroche" APP_AUTHOR_URL = "https://www.digitalcuriosity.center/about-us/#" APP_GITHUB_URL = "https://github.com/jlar0che/FallacyFinder" +HF_ROUTER_API_URL = os.getenv("HF_API_URL", "https://router.huggingface.co/v1/chat/completions") + + +def _load_local_dotenv() -> None: + try: + dotenv_path = BASE_DIR / ".env" + + if not dotenv_path.exists(): + return + + with open(dotenv_path, "r", encoding="utf-8") as f: + for raw in f: + line = raw.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + key, val = line.split("=", 1) + key = key.strip() + val = val.strip().strip('"').strip("'") + + if key and os.getenv(key) is None: + os.environ[key] = val + + + except Exception: + pass + + +# Load local .env early so DEFAULT_SETTINGS and secret helpers can pick it up +_load_local_dotenv() def _instance_dir() -> Path: @@ -517,6 +548,7 @@ def ensure_dirs() -> None: "OLLAMA_MODEL": os.getenv("OLLAMA_MODEL", "phi4:14b"), "OPENAI_BASE_URL": os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1").rstrip("/"), "OPENAI_MODEL": os.getenv("OPENAI_MODEL", "gpt-4.1-mini"), + "HUGGINGFACE_MODEL": os.getenv("HUGGINGFACE_MODEL", "meta-llama/Llama-3.1-8B-Instruct:cerebras"), "OLLAMA_TIMEOUT": float(os.getenv("OLLAMA_TIMEOUT", "1000")), "OLLAMA_TEMPERATURE": float(os.getenv("OLLAMA_TEMPERATURE", "0")), "OLLAMA_TOP_P": float(os.getenv("OLLAMA_TOP_P", "1")), @@ -540,18 +572,69 @@ def ensure_dirs() -> None: DEBUG_HIGHLIGHT = os.getenv("DEBUG_HIGHLIGHT", "0") == "1" +def _secrets_path() -> str: + os.makedirs(INSTANCE_DIR, exist_ok=True) + return str(INSTANCE_DIR / "secrets.json") + + +def _load_secrets() -> dict[str, str]: + path = _secrets_path() + if not os.path.exists(path): + return {} + try: + with open(path, "r", encoding="utf-8") as f: + payload = json.load(f) + if isinstance(payload, dict): + return {str(k): str(v) for k, v in payload.items() if str(v or "").strip()} + except Exception: + pass + return {} + + +def _save_secrets(payload: dict[str, str]) -> None: + path = _secrets_path() + clean = {str(k): str(v) for k, v in (payload or {}).items() if str(v or "").strip()} + with open(path, "w", encoding="utf-8") as f: + json.dump(clean, f, indent=2) + + +def _set_secret(name: str, value: str | None) -> None: + key = str(name or "").strip().upper() + if not key: + return + secrets_payload = _load_secrets() + token = str(value or "").strip() + if token: + secrets_payload[key] = token + else: + secrets_payload.pop(key, None) + _save_secrets(secrets_payload) + + +def _get_secret(name: str) -> str: + key = str(name or "").strip().upper() + if not key: + return "" + return str(_load_secrets().get(key) or "").strip() + + def get_openai_api_key() -> str: - return str(os.getenv("OPENAI_API_KEY") or "").strip() + return str(os.getenv("OPENAI_API_KEY") or _get_secret("OPENAI_API_KEY") or "").strip() + + +def get_huggingface_api_key() -> str: + return str(os.getenv("HUGGINGFACE_API_KEY") or _get_secret("HUGGINGFACE_API_KEY") or "").strip() + def _settings_for_persistence(settings: dict[str, Any]) -> dict[str, Any]: persisted = dict(settings or {}) persisted.pop("OPENAI_API_KEY", None) persisted.pop("OPENAI_API_KEY_CONFIGURED", None) + persisted.pop("HUGGINGFACE_API_KEY", None) + persisted.pop("HUGGINGFACE_API_KEY_CONFIGURED", None) return persisted - - def _settings_path() -> str: os.makedirs(INSTANCE_DIR, exist_ok=True) return str(INSTANCE_DIR / "settings.json") @@ -586,7 +669,7 @@ def apply_bool(name: str) -> None: if raw is not None: overridden[name] = str(raw).strip().lower() in {"1", "true", "yes", "on"} - for key in ("AI_PROVIDER", "OLLAMA_BASE_URL", "OLLAMA_MODEL", "OPENAI_BASE_URL", "OPENAI_MODEL", "UI_LANGUAGE"): + for key in ("AI_PROVIDER", "OLLAMA_BASE_URL", "OLLAMA_MODEL", "OPENAI_BASE_URL", "OPENAI_MODEL", "HUGGINGFACE_MODEL", "UI_LANGUAGE"): apply_str(key) for key in ("OLLAMA_TOP_K", "FALLACY_MIN_PARAGRAPH_CHARS", "FALLACY_CONTEXT_RADIUS", "FALLACY_CONTEXT_PREVIEW_CHARS", "RELATED_FALLACY_COUNT"): @@ -619,6 +702,7 @@ def load_settings() -> dict[str, Any]: if isinstance(saved, dict): saved = dict(saved) saved.pop("OPENAI_API_KEY", None) + saved.pop("HUGGINGFACE_API_KEY", None) settings.update(saved) except Exception: pass @@ -628,6 +712,8 @@ def load_settings() -> dict[str, Any]: settings["RELATED_FALLACY_COUNT"] = _sanitize_related_fallacy_count(settings.get("RELATED_FALLACY_COUNT"), default=DEFAULT_SETTINGS["RELATED_FALLACY_COUNT"]) settings["OPENAI_API_KEY"] = "" settings["OPENAI_API_KEY_CONFIGURED"] = bool(get_openai_api_key()) + settings["HUGGINGFACE_API_KEY"] = "" + settings["HUGGINGFACE_API_KEY_CONFIGURED"] = bool(get_huggingface_api_key()) return settings @@ -641,6 +727,8 @@ def current_model_label(settings: dict[str, Any]) -> str: provider = (settings.get("AI_PROVIDER") or "ollama").lower() if provider == "openai": return str(settings.get("OPENAI_MODEL") or "OpenAI") + if provider == "huggingface": + return str(settings.get("HUGGINGFACE_MODEL") or "Hugging Face") return str(settings.get("OLLAMA_MODEL") or "Ollama") @@ -683,6 +771,7 @@ def parse_settings_form(form) -> dict[str, Any]: settings["OLLAMA_MODEL"] = (form.get("OLLAMA_MODEL") or settings["OLLAMA_MODEL"]).strip() settings["OPENAI_BASE_URL"] = (form.get("OPENAI_BASE_URL") or settings["OPENAI_BASE_URL"]).strip().rstrip("/") settings["OPENAI_MODEL"] = (form.get("OPENAI_MODEL") or settings["OPENAI_MODEL"]).strip() + settings["HUGGINGFACE_MODEL"] = (form.get("HUGGINGFACE_MODEL") or settings.get("HUGGINGFACE_MODEL") or DEFAULT_SETTINGS["HUGGINGFACE_MODEL"]).strip() def get_float(name, fallback): raw = (form.get(name) or "").strip() @@ -719,6 +808,45 @@ def get_checkbox(name: str) -> bool: return settings +def _validate_huggingface_connection(model_name: str, api_key: str, timeout: float = 10.0, connect_timeout: float = 5.0) -> dict[str, Any]: + if not model_name: + raise ValueError("Please enter a Hugging Face model before testing the connection.") + if not api_key: + raise ValueError("HUGGINGFACE_API_KEY is not configured.") + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + payload = { # cheapest test possible + "model": model_name, + "messages": [ + {"role": "system", "content": "Reply with the single word ok."}, + {"role": "user", "content": "ping"}, + ], + "max_tokens": 1, + "temperature": 0, + } + + resp = requests.post(HF_ROUTER_API_URL, headers=headers, json=payload, timeout=(connect_timeout, timeout)) + resp.raise_for_status() + result = resp.json() + + choices = result.get("choices") if isinstance(result, dict) else None + + if not (isinstance(choices, list) and choices): + raise ValueError("Hugging Face returned a weird response format.") + + resolved_id = str(model_name) + return { + "provider": "huggingface", + "base_url": HF_ROUTER_API_URL, + "models": [resolved_id], + "models_count": 1, + } + + def list_ollama_models(base_url: str, timeout: float = 10.0, connect_timeout: float = 5.0) -> list[str]: url = f"{base_url.rstrip('/')}/api/tags" r = requests.get(url, timeout=(connect_timeout, timeout)) @@ -770,6 +898,11 @@ def validate_provider_connection(settings: dict[str, Any], timeout: float = 10.0 "models_count": len(models), } + if provider == "huggingface": + model_name = str(settings.get("HUGGINGFACE_MODEL") or "").strip() + api_key = get_huggingface_api_key() + return _validate_huggingface_connection(model_name, api_key, timeout=timeout, connect_timeout=connect_timeout) + base_url = str(settings.get("OLLAMA_BASE_URL") or "").strip().rstrip("/") model_name = str(settings.get("OLLAMA_MODEL") or "").strip() if not base_url: @@ -912,6 +1045,8 @@ def _model_identity_from_settings(settings: dict[str, Any]) -> tuple[str, str, s provider = (settings.get("AI_PROVIDER") or "ollama").strip().lower() if provider == "openai": model_name = str(settings.get("OPENAI_MODEL") or "OpenAI") + elif provider == "huggingface": + model_name = str(settings.get("HUGGINGFACE_MODEL") or "Hugging Face") else: model_name = str(settings.get("OLLAMA_MODEL") or "Ollama") model_key = f"{provider}::{model_name}" @@ -1604,10 +1739,49 @@ def openai_chat(messages: list[dict[str, str]], settings: dict[str, Any], model: return (((data.get("choices") or [{}])[0].get("message") or {}).get("content", "")) or "" +def huggingface_chat(messages: list[dict[str, str]], settings: dict[str, Any], model: str | None = None, timeout_seconds: float | None = None) -> str: + api_key = get_huggingface_api_key() + if not api_key: + raise ValueError("HF API key is not set in the server environment.") + + resolved_model = model or settings.get("HUGGINGFACE_MODEL") or os.getenv("HF_MODEL", "meta-llama/Llama-3.1-8B-Instruct:cerebras") + + read_timeout = float(timeout_seconds if timeout_seconds is not None else settings.get("OLLAMA_TIMEOUT", 60)) + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + payload = { + "model": resolved_model, + "messages": messages or [], + "max_tokens": 100, + "temperature": 0.7, + } + response = requests.post(HF_ROUTER_API_URL, headers=headers, json=payload, timeout=(3, read_timeout)) + response.raise_for_status() + result = response.json() + + choices = result.get("choices") if isinstance(result, dict) else None + if isinstance(choices, list) and choices: + first = choices[0] + if isinstance(first, dict): + message = first.get("message") or {} + if isinstance(message, dict): + content = message.get("content") + if isinstance(content, str): + return content.strip() + if content is not None: + return str(content).strip() + + raise ValueError("HF returned a weird response format.") + + def provider_chat(messages: list[dict[str, str]], settings: dict[str, Any], seed: int | None = None, timeout_seconds: float | None = None, job_id: str | None = None, cancel_check=None) -> str: provider = (settings.get("AI_PROVIDER") or "ollama").lower() if provider == "openai": return openai_chat(messages, settings, seed=seed, timeout_seconds=timeout_seconds) + if provider == "huggingface": + return huggingface_chat(messages, settings, model=None, timeout_seconds=timeout_seconds) return ollama_chat(messages, settings, seed=seed, timeout_seconds=timeout_seconds, job_id=job_id, cancel_check=cancel_check) @@ -3630,7 +3804,10 @@ def save_settings_route(): models: list[str] = [] model_error = None save_error = None + openai_api_key_input = str(request.form.get("OPENAI_API_KEY") or "").strip() try: + if openai_api_key_input: + _set_secret("OPENAI_API_KEY", openai_api_key_input) provider_info = validate_provider_connection(settings) if provider_info.get("provider") == "ollama": models = provider_info.get("models") or [] @@ -3696,13 +3873,22 @@ def api_openai_test(): payload = payload or {} base_url = str(payload.get("base_url") or load_settings()["OPENAI_BASE_URL"]).strip().rstrip("/") model_name = str(payload.get("model") or "").strip() + api_key = str(payload.get("api_key") or get_openai_api_key() or "").strip() try: settings = { "AI_PROVIDER": "openai", "OPENAI_BASE_URL": base_url, "OPENAI_MODEL": model_name, } - info = validate_provider_connection(settings) + if not api_key: + raise ValueError("OPENAI_API_KEY is not configured.") + models = list_openai_models(base_url, api_key) + info = { + "provider": "openai", + "base_url": base_url, + "models_count": len(models), + "models": models, + } return jsonify({ "ok": True, "base_url": info.get("base_url") or base_url, @@ -3713,6 +3899,23 @@ def api_openai_test(): return jsonify({"ok": False, "base_url": base_url, "error": str(e)}), 400 +@app.post("/api/huggingface/test") +def api_huggingface_test(): + payload = request.get_json(silent=True) if request.is_json else request.form + payload = payload or {} + model_name = str(payload.get("model") or load_settings().get("HUGGINGFACE_MODEL") or "").strip() + try: + info = _validate_huggingface_connection(model_name, get_huggingface_api_key()) + return jsonify({ + "ok": True, + "base_url": info.get("base_url"), + "models_count": info.get("models_count") or 0, + "models": (info.get("models") or [])[:25], + }) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 400 + + @app.post("/api/history/check") def api_history_check(): url = (request.form.get("url") or "").strip() diff --git a/templates/settings.html b/templates/settings.html index f5cfb76..9b5d938 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -114,6 +114,9 @@

{{ t('settings.page_title') }}

{% if settings.AI_PROVIDER == 'openai' %} {% set provider_label = 'OPENAI' %} {% set provider_host = ((settings.OPENAI_BASE_URL or '').replace('https://', '').replace('http://', '').split('/')[0]) %} + {% elif settings.AI_PROVIDER == 'huggingface' %} + {% set provider_label = 'HUGGINGFACE' %} + {% set provider_host = 'huggingface.co' %} {% else %} {% set provider_label = 'OLLAMA' %} {% set provider_host = ((settings.OLLAMA_BASE_URL or '').replace('https://', '').replace('http://', '').split('/')[0]) %} @@ -138,6 +141,7 @@

{{ t('settings.page_title') }}

@@ -188,16 +192,13 @@

{{ t('settings.page_title') }}

-
- {% if settings.OPENAI_API_KEY_CONFIGURED %}Configured in server environment{% else %}Not configured in server environment{% endif %} - ENV -
+
-
This key is never shown in the UI and is read only from the OPENAI_API_KEY environment variable.
+
Stored server-side only; never shown back in this UI. {% if settings.OPENAI_API_KEY_CONFIGURED %}Current status: configured.{% else %}Current status: not configured.{% endif %}
@@ -206,6 +207,27 @@

{{ t('settings.page_title') }}

+
+
+
Hugging Face
+
+
+ + +
+
+
Stored server-side only; never shown in this UI.
+
+
+ +
+
+
+
+
+
+
+
{{ t('settings.fallacy_scanning_help') }}
@@ -330,6 +352,7 @@

{{ t('settings.page_title') }}

const saveHint = document.getElementById('settingsSaveHint'); const ollamaSection = document.getElementById('ollamaSection'); const openaiSection = document.getElementById('openaiSection'); + const huggingfaceSection = document.getElementById('huggingfaceSection'); const toggleAdvancedSwitch = document.getElementById('toggleAdvancedSwitch'); const advancedOptions = document.getElementById('advancedOptions'); @@ -340,8 +363,13 @@

{{ t('settings.page_title') }}

const openaiBaseUrlInput = document.getElementById('openaiBaseUrl'); const openaiModelInput = document.getElementById('openaiModelInput'); + const openaiApiKeyInput = document.getElementById('openaiApiKeyInput'); const openaiTestBtn = document.getElementById('testOpenaiBtn'); const openaiResultBox = document.getElementById('openaiTestResult'); + + const huggingfaceModelInput = document.getElementById('huggingfaceModelInput'); + const huggingfaceTestBtn = document.getElementById('testHuggingfaceBtn'); + const huggingfaceResultBox = document.getElementById('huggingfaceTestResult'); const settingsTestAlertHost = document.getElementById('settingsTestAlertHost'); let lastSuccessfulSignature = ''; @@ -357,7 +385,14 @@

{{ t('settings.page_title') }}

return JSON.stringify({ provider: 'openai', base_url: (openaiBaseUrlInput && openaiBaseUrlInput.value || '').trim(), - model: (openaiModelInput && openaiModelInput.value || '').trim() + model: (openaiModelInput && openaiModelInput.value || '').trim(), + has_token_input: !!((openaiApiKeyInput && openaiApiKeyInput.value || '').trim()) + }); + } + if (activeProvider() === 'huggingface') { + return JSON.stringify({ + provider: 'huggingface', + model: (huggingfaceModelInput && huggingfaceModelInput.value || '').trim() }); } return JSON.stringify({ @@ -378,10 +413,12 @@

{{ t('settings.page_title') }}

} function buildProviderIssueMessage() { - const provider = activeProvider() === 'openai' ? 'OPENAI' : 'OLLAMA'; + const provider = activeProvider() === 'openai' + ? 'OPENAI' + : (activeProvider() === 'huggingface' ? 'HUGGINGFACE' : 'OLLAMA'); const host = activeProvider() === 'openai' ? connectionHostFromUrl(openaiBaseUrlInput && openaiBaseUrlInput.value) - : connectionHostFromUrl(baseUrlInput && baseUrlInput.value); + : (activeProvider() === 'huggingface' ? 'huggingface.co' : connectionHostFromUrl(baseUrlInput && baseUrlInput.value)); return connectionIssueTemplate .replace('__PROVIDER__', provider) .replace('__HOST__', host || savedUrlLabel); @@ -487,6 +524,7 @@

{{ t('settings.page_title') }}

const provider = activeProvider(); ollamaSection.style.display = provider === 'ollama' ? '' : 'none'; openaiSection.style.display = provider === 'openai' ? '' : 'none'; + huggingfaceSection.style.display = provider === 'huggingface' ? '' : 'none'; removeProviderIssueAlert(); invalidateConnectionTest({{ t('settings.save_hint')|tojson }}); } @@ -512,6 +550,11 @@

{{ t('settings.page_title') }}

openaiResultBox.className = className || 'small ff-muted'; } + function setHuggingFaceTestState(message, className) { + huggingfaceResultBox.textContent = message || ''; + huggingfaceResultBox.className = className || 'small ff-muted'; + } + function setTestBusy(isBusy) { testBtn.disabled = !!isBusy; testBtn.textContent = isBusy ? {{ t('common.testing')|tojson }} : {{ t('settings.test_ollama')|tojson }}; @@ -522,6 +565,11 @@

{{ t('settings.page_title') }}

openaiTestBtn.textContent = isBusy ? {{ t('common.testing')|tojson }} : {{ t('settings.test_api')|tojson }}; } + function setHuggingFaceTestBusy(isBusy) { + huggingfaceTestBtn.disabled = !!isBusy; + huggingfaceTestBtn.textContent = isBusy ? {{ t('common.testing')|tojson }} : {{ t('settings.test_api')|tojson }}; + } + async function refreshOllamaModelOptions(fd, expectedSignature) { const modelResp = await fetch('/api/ollama/models', { method: 'POST', body: fd, cache: 'no-store' }); const modelData = await modelResp.json(); @@ -587,7 +635,8 @@

{{ t('settings.page_title') }}

if (provider === 'openai') { const payload = { base_url: (openaiBaseUrlInput.value || '').trim(), - model: (openaiModelInput.value || '').trim() + model: (openaiModelInput.value || '').trim(), + api_key: (openaiApiKeyInput.value || '').trim() }; setOpenAITestState({{ t('settings.checking_api', host='__HOST__')|tojson }}.replace('__HOST__', (payload.base_url || {{ t('settings.saved_url')|tojson }})), 'small ff-muted'); const resp = await fetch('/api/openai/test', { @@ -606,6 +655,27 @@

{{ t('settings.page_title') }}

return; } + if (provider === 'huggingface') { + const payload = { + model: (huggingfaceModelInput.value || '').trim() + }; + setHuggingFaceTestState({{ t('settings.checking_api', host='__HOST__')|tojson }}.replace('__HOST__', 'huggingface.co'), 'small ff-muted'); + const resp = await fetch('/api/huggingface/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + cache: 'no-store' + }); + const data = await resp.json(); + if (initialSignature !== getConnectionSignature()) return; + if (!data.ok) { + showProviderIssueAlert(buildProviderIssueMessage()); + return; + } + removeProviderIssueAlert(); + return; + } + const typedBaseUrl = (baseUrlInput.value || '').trim(); const fd = new FormData(); fd.append('base_url', typedBaseUrl); @@ -626,13 +696,15 @@

{{ t('settings.page_title') }}

if (initialSignature !== getConnectionSignature()) return; setTestState('', 'small ff-muted'); setOpenAITestState('', 'small ff-muted'); + setHuggingFaceTestState('', 'small ff-muted'); } } async function testOpenAIConnection() { const payload = { base_url: (openaiBaseUrlInput.value || '').trim(), - model: (openaiModelInput.value || '').trim() + model: (openaiModelInput.value || '').trim(), + api_key: (openaiApiKeyInput.value || '').trim() }; setOpenAITestBusy(true); setOpenAITestState({{ t('settings.checking_api', host='__HOST__')|tojson }}.replace('__HOST__', (payload.base_url || {{ t('settings.saved_url')|tojson }})), 'small ff-muted'); @@ -666,6 +738,42 @@

{{ t('settings.page_title') }}

} } + async function testHuggingFaceConnection() { + const payload = { + model: (huggingfaceModelInput.value || '').trim() + }; + setHuggingFaceTestBusy(true); + setHuggingFaceTestState({{ t('settings.checking_api', host='__HOST__')|tojson }}.replace('__HOST__', 'huggingface.co'), 'small ff-muted'); + setSaveEnabled(false, {{ t('settings.waiting_successful_test')|tojson }}); + + try { + const resp = await fetch('/api/huggingface/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + cache: 'no-store' + }); + const data = await resp.json(); + if (!data.ok) { + setHuggingFaceTestState('', 'small ff-muted'); + removeManagedTestAlerts(); + removeProviderIssueAlert(); + showManagedTestAlert({{ t('settings.connection_failed_short')|tojson }}, 6000, 'error'); + invalidateConnectionTest({{ t('settings.connection_failed_retest')|tojson }}, { preserveAlerts: true }); + return; + } + + setHuggingFaceTestState('', 'small ff-muted'); + removeProviderIssueAlert(); + lastSuccessfulSignature = getConnectionSignature(); + removeManagedTestAlerts(); + showManagedTestAlert({{ t('settings.connection_successful_models', count='__COUNT__')|tojson }}.replace('__COUNT__', String(data.models_count)), 6000, 'success'); + setSaveEnabled(true, {{ t('settings.save_hint')|tojson }}); + } finally { + setHuggingFaceTestBusy(false); + } + } + [baseUrlInput, modelSelect].forEach((el) => { if (!el) return; el.addEventListener('input', function () { @@ -680,7 +788,7 @@

{{ t('settings.page_title') }}

}); }); - [openaiBaseUrlInput, openaiModelInput].forEach((el) => { + [openaiBaseUrlInput, openaiModelInput, openaiApiKeyInput].forEach((el) => { if (!el) return; el.addEventListener('input', function () { removeProviderIssueAlert(); @@ -694,6 +802,20 @@

{{ t('settings.page_title') }}

}); }); + [huggingfaceModelInput].forEach((el) => { + if (!el) return; + el.addEventListener('input', function () { + removeProviderIssueAlert(); + setHuggingFaceTestState('', 'small ff-muted'); + invalidateConnectionTest({{ t('settings.api_settings_changed')|tojson }}); + }); + el.addEventListener('change', function () { + removeProviderIssueAlert(); + setHuggingFaceTestState('', 'small ff-muted'); + invalidateConnectionTest({{ t('settings.api_settings_changed')|tojson }}); + }); + }); + testBtn.addEventListener('click', function () { fetchOllamaModels().catch((err) => { setTestBusy(false); @@ -714,6 +836,16 @@

{{ t('settings.page_title') }}

}); }); + huggingfaceTestBtn.addEventListener('click', function () { + testHuggingFaceConnection().catch((err) => { + setHuggingFaceTestBusy(false); + setHuggingFaceTestState('', 'small ff-muted'); + removeManagedTestAlerts(); + showManagedTestAlert({{ t('settings.connection_failed_short')|tojson }}, 6000, 'error'); + invalidateConnectionTest({{ t('settings.connection_failed_retest')|tojson }}, { preserveAlerts: true }); + }); + }); + settingsForm.addEventListener('submit', function (event) { if (!saveBtn || !saveBtn.disabled) return; event.preventDefault();