diff --git a/README.md b/README.md index a71daec..f4f9fb4 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,17 @@ start.cmd запускает сервер и печатает адреса ## Настройки -Все через `.env` в корне проекта (образец — `.env.example`); в репозиторий он -не попадает. Переменные окружения имеют приоритет над файлом. +**Проще всего — в браузере:** вкладка **⚙**. Там указывается адрес Ollama, +кнопка «Проверить связь» говорит, отвечает ли он и сколько там моделей, а модели +выбираются из списка реально установленных. Сохранённое применяется сразу, +перезапуск не нужен. Если Ollama не отвечает, наверху появляется предупреждение +со ссылкой прямо в настройки. + +Инсталлятор спрашивает адрес в консоли при установке, но это можно пропустить +(Enter) и задать потом в браузере. + +Под капотом всё это — файл `.env` в корне проекта (образец — `.env.example`); +в репозиторий он не попадает. Переменные окружения имеют приоритет над файлом. | Переменная | По умолчанию | Зачем | |---|---|---| @@ -55,7 +64,13 @@ start.cmd запускает сервер и печатает адреса | `HLAMINGO_CHAT_MODEL` | `qwen2.5:7b` | разбор фраз и теги | | `HLAMINGO_EMBED_MODEL` | `nomic-embed-text:latest` | поиск по смыслу | | `HLAMINGO_PORT` | `5000` | порт веб-интерфейса | -| `HLAMINGO_DATA_DIR` | — | другая папка для данных; используется тестами | +| `HLAMINGO_DATA_DIR` | — | другая папка для данных и `.env`; используется тестами | + +Из браузера правятся только `OLLAMA_HOST` и две модели — по белому списку. +Произвольные ключи в `.env` через веб записать нельзя: этот файл читает +приложение при запуске. Адрес проверяется на формат, имена моделей — на +недопустимые символы, и если хоть одно значение неверно, не применяется +ни одно (иначе половина настроек принята, половина отвергнута). ## Как пользоваться diff --git a/hlamingo/app.py b/hlamingo/app.py index dbb6258..3de41fe 100644 --- a/hlamingo/app.py +++ b/hlamingo/app.py @@ -7,9 +7,9 @@ import threading from flask import Flask, request, jsonify, send_from_directory -from . import store, search, brain, ollama +from . import store, search, brain, ollama, config from .config import (PROJECT_DIR, TEMPLATES_DIR, PHOTOS_DIR, HOST, PORT, - MAX_UPLOAD_BYTES, ALLOWED_PHOTO_EXT, CHAT_MODEL, OLLAMA_HOST) + MAX_UPLOAD_BYTES, ALLOWED_PHOTO_EXT) logger = logging.getLogger(__name__) @@ -46,7 +46,72 @@ def favicon(): @app.route("/api/health") def health(): - return jsonify({"ollama": ollama.check(), "host": OLLAMA_HOST, "model": CHAT_MODEL}) + return jsonify({"ollama": ollama.check(), "host": config.OLLAMA_HOST, + "model": config.CHAT_MODEL, + # Пока ничего не записано и ИИ не отвечает — человек, скорее + # всего, только что поставил приложение: покажем настройки. + "fresh": not config.DB_FILE.exists()}) + + +# ============================================================ +# Настройки из браузера +# +# Чтобы после установки не возвращаться к консоли: адрес Ollama и модели +# задаются с того же телефона, а модели выбираются из реально имеющихся. +# ============================================================ +@app.route("/api/config") +def api_config(): + return jsonify({ + "ollama_host": config.OLLAMA_HOST, + "chat_model": config.CHAT_MODEL, + "embed_model": config.EMBED_MODEL, + "available": ollama.check(), + "models": ollama.models(), + "env_file": str(config.ENV_FILE), + }) + + +@app.route("/api/config/probe", methods=["POST"]) +def api_config_probe(): + """Проверяет адрес, ничего не сохраняя: что там за Ollama и какие модели.""" + host = ((request.get_json(silent=True) or {}).get("ollama_host") or "").strip() + if not host: + return jsonify({"ok": False, "reply": "Адрес не указан."}), 400 + + was = config.OLLAMA_HOST + try: + config.OLLAMA_HOST = host.rstrip("/") + alive = ollama.check() + models = ollama.models() if alive else [] + finally: + config.OLLAMA_HOST = was # проба не должна менять настройки + + if not alive: + return jsonify({"ok": False, "models": [], + "reply": "По адресу %s Ollama не отвечает." % host}) + return jsonify({"ok": True, "models": models, + "reply": "Ollama отвечает, моделей: %d." % len(models)}) + + +@app.route("/api/config", methods=["POST"]) +def api_config_save(): + data = request.get_json(silent=True) or {} + try: + changed = config.save_settings({ + "OLLAMA_HOST": data.get("ollama_host"), + "HLAMINGO_CHAT_MODEL": data.get("chat_model"), + "HLAMINGO_EMBED_MODEL": data.get("embed_model"), + }) + except ValueError as e: + return jsonify({"ok": False, "reply": str(e)}), 400 + + if not changed: + return jsonify({"ok": True, "reply": "Менять нечего."}) + alive = ollama.check() + reply = "Сохранил в .env. " + ("Ollama отвечает." if alive else + "Но Ollama по этому адресу молчит.") + return jsonify({"ok": True, "reply": reply, "available": alive, + "changed": changed}) # ============================================================ @@ -610,7 +675,7 @@ def main(): PHOTOS_DIR.mkdir(parents=True, exist_ok=True) alive = ollama.check() - logger.info("Ollama %s: %s", OLLAMA_HOST, "доступен" if alive else "НЕ отвечает") + logger.info("Ollama %s: %s", config.OLLAMA_HOST, "доступен" if alive else "НЕ отвечает") if not alive: logger.warning("Без Ollama работает запись, обзор и поиск по названию. " "Разбор фраз — упрощённый, поиск по смыслу отключён.") diff --git a/hlamingo/brain.py b/hlamingo/brain.py index 34289c0..10f2174 100644 --- a/hlamingo/brain.py +++ b/hlamingo/brain.py @@ -13,7 +13,7 @@ import json import logging from . import ollama -from .config import CHAT_MODEL +from . import config logger = logging.getLogger(__name__) @@ -121,11 +121,12 @@ def _extract_json(content, opener="{", closer="}"): # ============================================================ # Разбор фразы # ============================================================ -def parse(text, model=CHAT_MODEL): +def parse(text, model=None): """Фраза → структура. Всегда возвращает словарь, никогда не бросает.""" text = (text or "").strip() if not text: return _blank("unknown") + model = model or config.CHAT_MODEL content = ollama.chat(model, [ {"role": "system", "content": PARSE_SYSTEM}, @@ -288,11 +289,12 @@ def _split_places(text): # ============================================================ # Синонимы # ============================================================ -def aliases(name, note=None, model=CHAT_MODEL): +def aliases(name, note=None, model=None): """3-7 альтернативных названий. Пустой список, если модель недоступна.""" name = (name or "").strip() if not name: return [] + model = model or config.CHAT_MODEL prompt = name + (" (" + note + ")" if note else "") content = ollama.chat(model, [ {"role": "system", "content": ALIAS_SYSTEM}, diff --git a/hlamingo/config.py b/hlamingo/config.py index c50535c..2a61d1e 100644 --- a/hlamingo/config.py +++ b/hlamingo/config.py @@ -36,6 +36,102 @@ def _load_env_file(): _load_env_file() +# Своя папка для данных, если задана. Определяем здесь, потому что от неё +# зависит и путь к .env. +_ROOT_OVERRIDE = Path(os.environ["HLAMINGO_DATA_DIR"]) \ + if os.environ.get("HLAMINGO_DATA_DIR") else None + +# Файл настроек. В песочнице он тоже свой: иначе тест перезаписал бы рабочий +# .env — и однажды уже перезаписал. +ENV_FILE = (_ROOT_OVERRIDE / ".env") if _ROOT_OVERRIDE else (_PROJECT / ".env") + +# Что разрешено менять из браузера. Белый список, а не любые ключи: в .env +# нельзя позволять писать произвольные строки — этот файл читает приложение +# при запуске. +EDITABLE = ("OLLAMA_HOST", "HLAMINGO_CHAT_MODEL", "HLAMINGO_EMBED_MODEL") + +_HOST_RE = None # ленивая компиляция: re нужен только при сохранении + + +def _validate(key, raw): + """Проверяет одно значение и возвращает нормализованное. Бросает ValueError.""" + import re as _re + + value = str(raw or "").strip() + if key == "OLLAMA_HOST": + if not _re.match(r"^https?://[\w.\-]+(:\d{1,5})?/?$", value): + raise ValueError("Адрес Ollama должен быть вида http://хост:11434") + return value.rstrip("/") + if value and not _re.match(r"^[\w./:\-]+$", value): + raise ValueError("Недопустимое имя модели: %s" % value) + return value + + +def save_settings(values): + """Пишет настройки в .env и применяет их на ходу. + + Перезапуск не нужен: остальные модули читают значения через `config.X`, + а не забирают копию при импорте. Возвращает список изменённых ключей. + + Сначала проверяем ВСЁ, и только потом применяем: иначе неверная модель + оставляла бы уже подменённый адрес Ollama — половина настроек применена, + половина отвергнута. + """ + checked = {} + for key, raw in (values or {}).items(): + if key in EDITABLE: + checked[key] = _validate(key, raw) + + changed = [key for key, value in checked.items() if _apply(key, value)] + if changed: + _write_env({key: current(key) for key in EDITABLE}) + return changed + + +def current(key): + """Текущее значение настройки по имени переменной окружения.""" + return {"OLLAMA_HOST": OLLAMA_HOST, + "HLAMINGO_CHAT_MODEL": CHAT_MODEL, + "HLAMINGO_EMBED_MODEL": EMBED_MODEL}.get(key, "") + + +def _apply(key, value): + """Обновляет значение в этом модуле. True, если оно действительно менялось.""" + global OLLAMA_HOST, CHAT_MODEL, EMBED_MODEL + if key == "OLLAMA_HOST" and value != OLLAMA_HOST: + OLLAMA_HOST = value + elif key == "HLAMINGO_CHAT_MODEL" and value and value != CHAT_MODEL: + CHAT_MODEL = value + elif key == "HLAMINGO_EMBED_MODEL" and value and value != EMBED_MODEL: + EMBED_MODEL = value + else: + return False + os.environ[key] = value + return True + + +def _write_env(values): + """Перезаписывает .env, сохраняя комментарии и посторонние строки.""" + if ENV_FILE.exists(): + lines = ENV_FILE.read_text(encoding="utf-8").splitlines() + else: + lines = ["# Локальные настройки этой установки. В репозиторий не попадает."] + + seen, out = set(), [] + for line in lines: + stripped = line.strip() + key = stripped.split("=", 1)[0].strip() if "=" in stripped else "" + if key in values and not stripped.startswith("#"): + out.append("%s=%s" % (key, values[key])) + seen.add(key) + else: + out.append(line) + for key, value in values.items(): + if key not in seen and value: + out.append("%s=%s" % (key, value)) + + ENV_FILE.write_text("\n".join(out) + "\n", encoding="utf-8") + # ============================================================ # Ollama # ============================================================ @@ -82,15 +178,14 @@ TEMPLATES_DIR = PROJECT_DIR / "templates" # а защита: тесты ОБЯЗАНЫ подставить свою папку, иначе затрут настоящую базу. # Один раз так уже потерялись реальные записи, потому что тест начинался # с удаления рабочей папки data. -_ROOT = Path(os.environ.get("HLAMINGO_DATA_DIR") or PROJECT_DIR) +_ROOT = _ROOT_OVERRIDE or PROJECT_DIR DATA_DIR = _ROOT / "data" DB_FILE = DATA_DIR / "db.json" -PHOTOS_DIR = (_ROOT / "photos") if os.environ.get("HLAMINGO_DATA_DIR") \ - else (PROJECT_DIR / "static" / "photos") +PHOTOS_DIR = (_ROOT / "photos") if _ROOT_OVERRIDE else (PROJECT_DIR / "static" / "photos") # True, когда работаем на настоящих данных пользователя. Тесты это проверяют # и отказываются что-либо удалять, если флаг стоит. -IS_LIVE_DATA = not os.environ.get("HLAMINGO_DATA_DIR") +IS_LIVE_DATA = _ROOT_OVERRIDE is None # ============================================================ # Веб diff --git a/hlamingo/ollama.py b/hlamingo/ollama.py index 162cb99..b4ff462 100644 --- a/hlamingo/ollama.py +++ b/hlamingo/ollama.py @@ -8,8 +8,11 @@ import json import logging import urllib.request -from .config import (OLLAMA_HOST, CHAT_TIMEOUT, EMBED_TIMEOUT, MAX_OUTPUT_TOKENS, - EMBED_MODEL, EMBED_DOC_PREFIX, EMBED_QUERY_PREFIX) +# Адрес и модели читаем через `config.X`, а не копией при импорте: их можно +# поменять из браузера, и это должно действовать сразу, без перезапуска. +from . import config +from .config import (CHAT_TIMEOUT, EMBED_TIMEOUT, MAX_OUTPUT_TOKENS, + EMBED_DOC_PREFIX, EMBED_QUERY_PREFIX) logger = logging.getLogger(__name__) @@ -17,7 +20,7 @@ logger = logging.getLogger(__name__) def check(): """Доступен ли сервер. Таймаут короткий: это дёргает баннер в UI.""" try: - req = urllib.request.Request(OLLAMA_HOST + "/api/tags") + req = urllib.request.Request(config.OLLAMA_HOST + "/api/tags") with urllib.request.urlopen(req, timeout=3) as r: return r.status == 200 except Exception: @@ -27,7 +30,7 @@ def check(): def models(): """Список установленных моделей (для диагностики).""" try: - req = urllib.request.Request(OLLAMA_HOST + "/api/tags") + req = urllib.request.Request(config.OLLAMA_HOST + "/api/tags") with urllib.request.urlopen(req, timeout=5) as r: data = json.loads(r.read().decode("utf-8")) return [m.get("model", "") for m in data.get("models", [])] @@ -50,7 +53,7 @@ def chat(model, messages, timeout=CHAT_TIMEOUT, num_predict=MAX_OUTPUT_TOKENS): } data = json.dumps(payload).encode("utf-8") req = urllib.request.Request( - OLLAMA_HOST + "/api/chat", data=data, + config.OLLAMA_HOST + "/api/chat", data=data, headers={"Content-Type": "application/json"}, ) try: @@ -72,9 +75,9 @@ def embed(text, is_query=False): if not text: return None prefix = EMBED_QUERY_PREFIX if is_query else EMBED_DOC_PREFIX - data = json.dumps({"model": EMBED_MODEL, "prompt": prefix + text}).encode("utf-8") + data = json.dumps({"model": config.EMBED_MODEL, "prompt": prefix + text}).encode("utf-8") req = urllib.request.Request( - OLLAMA_HOST + "/api/embeddings", data=data, + config.OLLAMA_HOST + "/api/embeddings", data=data, headers={"Content-Type": "application/json"}, ) try: diff --git a/templates/index.html b/templates/index.html index 9e50e36..2e5d5f0 100644 --- a/templates/index.html +++ b/templates/index.html @@ -42,6 +42,8 @@ nav button { flex:1; padding:11px 4px; font-size:15px; font-weight:600; background:none; border:none; border-bottom:3px solid transparent; color:var(--dim); cursor:pointer; } nav button.on { color:var(--fg); border-bottom-color:var(--accent); } + nav button.narrowTab { flex:0 0 48px; font-size:18px; } + code { background:var(--card); padding:1px 5px; border-radius:5px; font-size:14px; } main { padding:16px; max-width:720px; margin:0 auto; } .tab { display:none; } .tab.on { display:block; } @@ -131,6 +133,7 @@ + @@ -213,6 +216,34 @@
+ +Здесь настраивается связь с Ollama — это «мозги» + помощника. Без неё всё остальное работает, но не будет разбора фраз, + автоматических тегов и поиска по смыслу.
+ + + +Если Ollama на этом же компьютере — http://localhost:11434.
+ Если на другой машине — её адрес в локальной сети.
Список заполняется моделями, которые реально установлены.
+ Нужных нет — на машине с Ollama: ollama pull qwen2.5:7b
+ и ollama pull nomic-embed-text.