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.

+ +
+

+
+
@@ -254,21 +285,93 @@ const LAST = { function say(text) { $('#reply').style.display = 'block'; $('#reply').textContent = text || ''; } // ---------- вкладки ---------- -document.querySelectorAll('nav button').forEach(b => b.onclick = () => { - document.querySelectorAll('nav button').forEach(x => x.classList.toggle('on', x === b)); - document.querySelectorAll('.tab').forEach(t => t.classList.toggle('on', t.id === 'tab-' + b.dataset.tab)); - if (b.dataset.tab === 'tree') loadTree(); -}); +function openTab(name) { + document.querySelectorAll('nav button').forEach(x => + x.classList.toggle('on', x.dataset.tab === name)); + document.querySelectorAll('.tab').forEach(t => + t.classList.toggle('on', t.id === 'tab-' + name)); + if (name === 'tree') loadTree(); + if (name === 'setup') loadConfig(); + window.scrollTo({top: 0}); +} +document.querySelectorAll('nav button').forEach(b => b.onclick = () => openTab(b.dataset.tab)); // ---------- баннер ---------- api('/api/health').then(h => { - if (!h.ollama) { - $('#banner').style.display = 'block'; - $('#banner').textContent = 'ИИ недоступен (' + h.host + '). Кнопки работают полностью; ' - + 'не будет только синонимов и разбора свободных фраз.'; - } + if (h.ollama) return; + const b = $('#banner'); + b.style.display = 'block'; + b.textContent = h.fresh + ? 'Похоже, помощник только установлен, а Ollama по адресу ' + h.host + ' не отвечает. ' + : 'ИИ недоступен (' + h.host + '). Кнопки работают полностью; не будет только ' + + 'тегов и разбора свободных фраз. '; + const link = document.createElement('a'); + link.href = '#'; + link.textContent = 'Настроить →'; + link.style.color = 'inherit'; + link.style.fontWeight = '700'; + link.onclick = e => { e.preventDefault(); openTab('setup'); }; + b.appendChild(link); }).catch(() => {}); +// ---------- настройки ---------- +async function loadConfig() { + const d = await api('/api/config'); + $('#cfgHost').value = d.ollama_host || ''; + fillModels(d.models || [], d.chat_model, d.embed_model); + $('#cfgWhere').textContent = 'Настройки хранятся в файле ' + d.env_file + + ' — он не попадает в репозиторий.'; + if (!d.available) { + $('#cfgProbeResult').innerHTML = + '
Ollama по этому адресу не отвечает.
'; + } +} + +// Модель выбирается из установленных; текущее значение показываем даже если +// его нет в списке — иначе выбор молча сбросился бы на чужую модель. +function fillModels(models, chat, embed) { + const opts = extra => [''] + .concat((models.length ? models : []).map(m => + '')) + .concat(extra && !models.includes(extra) + ? [''] : []) + .join(''); + $('#cfgChat').innerHTML = opts(chat); + $('#cfgEmbed').innerHTML = opts(embed); + $('#cfgChat').value = chat || ''; + $('#cfgEmbed').value = embed || ''; +} + +$('#cfgProbe').onclick = async () => { + const btn = $('#cfgProbe'); + btn.disabled = true; btn.textContent = 'Проверяю…'; + try { + const d = await api('/api/config/probe', {method:'POST', + headers:{'Content-Type':'application/json'}, + body: JSON.stringify({ollama_host: $('#cfgHost').value.trim()})}); + $('#cfgProbeResult').innerHTML = '
' + esc(d.reply || '') + '
'; + if (d.ok) fillModels(d.models || [], $('#cfgChat').value, $('#cfgEmbed').value); + } finally { btn.disabled = false; btn.textContent = 'Проверить связь'; } +}; + +$('#cfgSave').onclick = async () => { + const btn = $('#cfgSave'); + btn.disabled = true; btn.textContent = 'Сохраняю…'; + try { + const d = await api('/api/config', {method:'POST', + headers:{'Content-Type':'application/json'}, + body: JSON.stringify({ollama_host: $('#cfgHost').value.trim(), + chat_model: $('#cfgChat').value, + embed_model: $('#cfgEmbed').value})}); + say(d.reply); + if (d.ok) { + $('#banner').style.display = d.available ? 'none' : 'block'; + loadConfig(); + } + } finally { btn.disabled = false; btn.textContent = 'Сохранить'; } +}; + // ---------- режимы ---------- const LABEL = {put:'Положить', take:'Забрать', move:'Переместить', delete:'Удалить'}; document.querySelectorAll('.modes button').forEach(b => b.onclick = () => { diff --git a/tests/test_all.py b/tests/test_all.py index 7d5b4a7..ac8872c 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -177,6 +177,45 @@ config.DB_FILE.write_text("{ битый json", encoding="utf-8") r.check("битая база не роняет", c.get("/api/tree").status_code == 200) r.check("пустой ввод не роняет", "reply" in j("POST", "/api/say", text=" ")) +print("\n== Настройки из браузера ==") +d = c.get("/api/config").get_json() +r.check("отдаёт текущие", d.get("ollama_host") and d.get("chat_model"), d) +r.check("говорит, где файл", str(d.get("env_file", "")).endswith(".env"), d.get("env_file")) + +was_host = config.OLLAMA_HOST +# Самое важное: файл настроек тоже в песочнице. Иначе тест перезаписывает +# рабочий .env пользователя — так однажды и случилось. +r.check("файл .env внутри песочницы", str(SANDBOX) in str(config.ENV_FILE), + config.ENV_FILE) + +d = j("POST", "/api/config", ollama_host="http://10.11.12.13:11434") +r.check("сохранилось", d.get("ok"), d) +r.check("применилось без перезапуска", config.OLLAMA_HOST == "http://10.11.12.13:11434", + config.OLLAMA_HOST) +r.check("ollama.py видит новый адрес", + c.get("/api/health").get_json()["host"] == "http://10.11.12.13:11434") +r.check("записалось в файл", config.ENV_FILE.exists() + and "10.11.12.13" in config.ENV_FILE.read_text(encoding="utf-8")) + +r.check("мусорный адрес — 400", + c.post("/api/config", json={"ollama_host": "не адрес"}).status_code == 400) +r.check("инъекция в имя модели — 400", + c.post("/api/config", + json={"ollama_host": "http://127.0.0.1:11434", + "chat_model": "модель\nOLLAMA_HOST=http://зло"}).status_code == 400) +# Отказ должен быть полным: адрес из того же запроса применяться не должен, +# иначе половина настроек принята, а половина отвергнута. +r.check("при отказе НИЧЕГО не применилось", + config.OLLAMA_HOST == "http://10.11.12.13:11434", config.OLLAMA_HOST) +r.check("посторонние ключи игнорируются", config.save_settings({"PATH": "/зло"}) == []) + +d = j("POST", "/api/config/probe", ollama_host="http://127.0.0.1:1") +r.check("проба мёртвого адреса не роняет", d.get("ok") is False, d) +r.check("проба не меняет настройку", config.OLLAMA_HOST == "http://10.11.12.13:11434", + config.OLLAMA_HOST) +j("POST", "/api/config", ollama_host=was_host) # возвращаем как было +r.check("адрес восстановлен", config.OLLAMA_HOST == was_host, config.OLLAMA_HOST) + print("\n== Разбор фразы ==") p = brain.fallback_parse("положил изоленту в коробку B2 на полке 1 в гараже") r.check("регулярки: put", p["action"] == "put" and store.norm_code(p["container"]) == "B2", p)