Настройка из браузера: вкладка ⚙ вместо правки файла
Инсталлятор спрашивает адрес Ollama в консоли, но это можно пропустить и задать всё с телефона: адрес, модель для разбора фраз, модель для поиска по смыслу. «Проверить связь» показывает, отвечает ли Ollama и сколько там моделей; сами модели выбираются из реально установленных, а не вводятся руками. Если Ollama молчит, предупреждение наверху ведёт прямо в настройки. Настройки применяются сразу: модули читают их через config.X, а не забирают копию при импорте, поэтому перезапуск не нужен. Из веба правятся только OLLAMA_HOST и две модели — по белому списку, потому что .env читает приложение при запуске. Адрес проверяется на формат, имена моделей — на недопустимые символы. Попутно исправлены два дефекта, найденных тестами: - .env в песочнице теперь тоже свой; до этого прогон тестов перезаписывал рабочий файл настроек; - значения проверяются все до применения: раньше неверное имя модели оставляло уже подменённый адрес Ollama. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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_CHAT_MODEL` | `qwen2.5:7b` | разбор фраз и теги |
|
||||||
| `HLAMINGO_EMBED_MODEL` | `nomic-embed-text:latest` | поиск по смыслу |
|
| `HLAMINGO_EMBED_MODEL` | `nomic-embed-text:latest` | поиск по смыслу |
|
||||||
| `HLAMINGO_PORT` | `5000` | порт веб-интерфейса |
|
| `HLAMINGO_PORT` | `5000` | порт веб-интерфейса |
|
||||||
| `HLAMINGO_DATA_DIR` | — | другая папка для данных; используется тестами |
|
| `HLAMINGO_DATA_DIR` | — | другая папка для данных и `.env`; используется тестами |
|
||||||
|
|
||||||
|
Из браузера правятся только `OLLAMA_HOST` и две модели — по белому списку.
|
||||||
|
Произвольные ключи в `.env` через веб записать нельзя: этот файл читает
|
||||||
|
приложение при запуске. Адрес проверяется на формат, имена моделей — на
|
||||||
|
недопустимые символы, и если хоть одно значение неверно, не применяется
|
||||||
|
ни одно (иначе половина настроек принята, половина отвергнута).
|
||||||
|
|
||||||
## Как пользоваться
|
## Как пользоваться
|
||||||
|
|
||||||
|
|||||||
+69
-4
@@ -7,9 +7,9 @@ import threading
|
|||||||
|
|
||||||
from flask import Flask, request, jsonify, send_from_directory
|
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,
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -46,7 +46,72 @@ def favicon():
|
|||||||
|
|
||||||
@app.route("/api/health")
|
@app.route("/api/health")
|
||||||
def 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)
|
PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
alive = ollama.check()
|
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:
|
if not alive:
|
||||||
logger.warning("Без Ollama работает запись, обзор и поиск по названию. "
|
logger.warning("Без Ollama работает запись, обзор и поиск по названию. "
|
||||||
"Разбор фраз — упрощённый, поиск по смыслу отключён.")
|
"Разбор фраз — упрощённый, поиск по смыслу отключён.")
|
||||||
|
|||||||
+5
-3
@@ -13,7 +13,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from . import ollama
|
from . import ollama
|
||||||
from .config import CHAT_MODEL
|
from . import config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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()
|
text = (text or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
return _blank("unknown")
|
return _blank("unknown")
|
||||||
|
model = model or config.CHAT_MODEL
|
||||||
|
|
||||||
content = ollama.chat(model, [
|
content = ollama.chat(model, [
|
||||||
{"role": "system", "content": PARSE_SYSTEM},
|
{"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 альтернативных названий. Пустой список, если модель недоступна."""
|
"""3-7 альтернативных названий. Пустой список, если модель недоступна."""
|
||||||
name = (name or "").strip()
|
name = (name or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
return []
|
return []
|
||||||
|
model = model or config.CHAT_MODEL
|
||||||
prompt = name + (" (" + note + ")" if note else "")
|
prompt = name + (" (" + note + ")" if note else "")
|
||||||
content = ollama.chat(model, [
|
content = ollama.chat(model, [
|
||||||
{"role": "system", "content": ALIAS_SYSTEM},
|
{"role": "system", "content": ALIAS_SYSTEM},
|
||||||
|
|||||||
+99
-4
@@ -36,6 +36,102 @@ def _load_env_file():
|
|||||||
|
|
||||||
_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
|
# Ollama
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -82,15 +178,14 @@ TEMPLATES_DIR = PROJECT_DIR / "templates"
|
|||||||
# а защита: тесты ОБЯЗАНЫ подставить свою папку, иначе затрут настоящую базу.
|
# а защита: тесты ОБЯЗАНЫ подставить свою папку, иначе затрут настоящую базу.
|
||||||
# Один раз так уже потерялись реальные записи, потому что тест начинался
|
# Один раз так уже потерялись реальные записи, потому что тест начинался
|
||||||
# с удаления рабочей папки data.
|
# с удаления рабочей папки data.
|
||||||
_ROOT = Path(os.environ.get("HLAMINGO_DATA_DIR") or PROJECT_DIR)
|
_ROOT = _ROOT_OVERRIDE or PROJECT_DIR
|
||||||
DATA_DIR = _ROOT / "data"
|
DATA_DIR = _ROOT / "data"
|
||||||
DB_FILE = DATA_DIR / "db.json"
|
DB_FILE = DATA_DIR / "db.json"
|
||||||
PHOTOS_DIR = (_ROOT / "photos") if os.environ.get("HLAMINGO_DATA_DIR") \
|
PHOTOS_DIR = (_ROOT / "photos") if _ROOT_OVERRIDE else (PROJECT_DIR / "static" / "photos")
|
||||||
else (PROJECT_DIR / "static" / "photos")
|
|
||||||
|
|
||||||
# True, когда работаем на настоящих данных пользователя. Тесты это проверяют
|
# True, когда работаем на настоящих данных пользователя. Тесты это проверяют
|
||||||
# и отказываются что-либо удалять, если флаг стоит.
|
# и отказываются что-либо удалять, если флаг стоит.
|
||||||
IS_LIVE_DATA = not os.environ.get("HLAMINGO_DATA_DIR")
|
IS_LIVE_DATA = _ROOT_OVERRIDE is None
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Веб
|
# Веб
|
||||||
|
|||||||
+10
-7
@@ -8,8 +8,11 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
from .config import (OLLAMA_HOST, CHAT_TIMEOUT, EMBED_TIMEOUT, MAX_OUTPUT_TOKENS,
|
# Адрес и модели читаем через `config.X`, а не копией при импорте: их можно
|
||||||
EMBED_MODEL, EMBED_DOC_PREFIX, EMBED_QUERY_PREFIX)
|
# поменять из браузера, и это должно действовать сразу, без перезапуска.
|
||||||
|
from . import config
|
||||||
|
from .config import (CHAT_TIMEOUT, EMBED_TIMEOUT, MAX_OUTPUT_TOKENS,
|
||||||
|
EMBED_DOC_PREFIX, EMBED_QUERY_PREFIX)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -17,7 +20,7 @@ logger = logging.getLogger(__name__)
|
|||||||
def check():
|
def check():
|
||||||
"""Доступен ли сервер. Таймаут короткий: это дёргает баннер в UI."""
|
"""Доступен ли сервер. Таймаут короткий: это дёргает баннер в UI."""
|
||||||
try:
|
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:
|
with urllib.request.urlopen(req, timeout=3) as r:
|
||||||
return r.status == 200
|
return r.status == 200
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -27,7 +30,7 @@ def check():
|
|||||||
def models():
|
def models():
|
||||||
"""Список установленных моделей (для диагностики)."""
|
"""Список установленных моделей (для диагностики)."""
|
||||||
try:
|
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:
|
with urllib.request.urlopen(req, timeout=5) as r:
|
||||||
data = json.loads(r.read().decode("utf-8"))
|
data = json.loads(r.read().decode("utf-8"))
|
||||||
return [m.get("model", "") for m in data.get("models", [])]
|
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")
|
data = json.dumps(payload).encode("utf-8")
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
OLLAMA_HOST + "/api/chat", data=data,
|
config.OLLAMA_HOST + "/api/chat", data=data,
|
||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json"},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
@@ -72,9 +75,9 @@ def embed(text, is_query=False):
|
|||||||
if not text:
|
if not text:
|
||||||
return None
|
return None
|
||||||
prefix = EMBED_QUERY_PREFIX if is_query else EMBED_DOC_PREFIX
|
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(
|
req = urllib.request.Request(
|
||||||
OLLAMA_HOST + "/api/embeddings", data=data,
|
config.OLLAMA_HOST + "/api/embeddings", data=data,
|
||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json"},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
|||||||
+113
-10
@@ -42,6 +42,8 @@
|
|||||||
nav button { flex:1; padding:11px 4px; font-size:15px; font-weight:600; background:none;
|
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; }
|
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.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; }
|
main { padding:16px; max-width:720px; margin:0 auto; }
|
||||||
.tab { display:none; } .tab.on { display:block; }
|
.tab { display:none; } .tab.on { display:block; }
|
||||||
|
|
||||||
@@ -131,6 +133,7 @@
|
|||||||
<button class="on" data-tab="do">Действие</button>
|
<button class="on" data-tab="do">Действие</button>
|
||||||
<button data-tab="find">Найти</button>
|
<button data-tab="find">Найти</button>
|
||||||
<button data-tab="tree">Обзор</button>
|
<button data-tab="tree">Обзор</button>
|
||||||
|
<button data-tab="setup" class="narrowTab">⚙</button>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -213,6 +216,34 @@
|
|||||||
<div id="findCards"></div>
|
<div id="findCards"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ================= Настройки ================= -->
|
||||||
|
<section class="tab" id="tab-setup">
|
||||||
|
<p class="hint" id="setupIntro">Здесь настраивается связь с Ollama — это «мозги»
|
||||||
|
помощника. Без неё всё остальное работает, но не будет разбора фраз,
|
||||||
|
автоматических тегов и поиска по смыслу.</p>
|
||||||
|
|
||||||
|
<label>Адрес Ollama</label>
|
||||||
|
<input type="text" id="cfgHost" placeholder="http://192.168.1.50:11434" autocomplete="off"
|
||||||
|
inputmode="url">
|
||||||
|
<p class="hint">Если Ollama на этом же компьютере — <code>http://localhost:11434</code>.
|
||||||
|
Если на другой машине — её адрес в локальной сети.</p>
|
||||||
|
<div class="row mt">
|
||||||
|
<button class="act ghost" id="cfgProbe">Проверить связь</button>
|
||||||
|
</div>
|
||||||
|
<div id="cfgProbeResult"></div>
|
||||||
|
|
||||||
|
<label>Модель для разбора фраз</label>
|
||||||
|
<select id="cfgChat"></select>
|
||||||
|
<label>Модель для поиска по смыслу</label>
|
||||||
|
<select id="cfgEmbed"></select>
|
||||||
|
<p class="hint">Список заполняется моделями, которые реально установлены.
|
||||||
|
Нужных нет — на машине с Ollama: <code>ollama pull qwen2.5:7b</code>
|
||||||
|
и <code>ollama pull nomic-embed-text</code>.</p>
|
||||||
|
|
||||||
|
<div class="mt"><button class="act" id="cfgSave">Сохранить</button></div>
|
||||||
|
<p class="hint" id="cfgWhere"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- ================= Обзор ================= -->
|
<!-- ================= Обзор ================= -->
|
||||||
<section class="tab" id="tab-tree">
|
<section class="tab" id="tab-tree">
|
||||||
<button class="act ghost" id="treeBtn">Обновить</button>
|
<button class="act ghost" id="treeBtn">Обновить</button>
|
||||||
@@ -254,21 +285,93 @@ const LAST = {
|
|||||||
function say(text) { $('#reply').style.display = 'block'; $('#reply').textContent = text || ''; }
|
function say(text) { $('#reply').style.display = 'block'; $('#reply').textContent = text || ''; }
|
||||||
|
|
||||||
// ---------- вкладки ----------
|
// ---------- вкладки ----------
|
||||||
document.querySelectorAll('nav button').forEach(b => b.onclick = () => {
|
function openTab(name) {
|
||||||
document.querySelectorAll('nav button').forEach(x => x.classList.toggle('on', x === b));
|
document.querySelectorAll('nav button').forEach(x =>
|
||||||
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('on', t.id === 'tab-' + b.dataset.tab));
|
x.classList.toggle('on', x.dataset.tab === name));
|
||||||
if (b.dataset.tab === 'tree') loadTree();
|
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 => {
|
api('/api/health').then(h => {
|
||||||
if (!h.ollama) {
|
if (h.ollama) return;
|
||||||
$('#banner').style.display = 'block';
|
const b = $('#banner');
|
||||||
$('#banner').textContent = 'ИИ недоступен (' + h.host + '). Кнопки работают полностью; '
|
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(() => {});
|
}).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 =
|
||||||
|
'<div class="card"><div class="addr taken">Ollama по этому адресу не отвечает.</div></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Модель выбирается из установленных; текущее значение показываем даже если
|
||||||
|
// его нет в списке — иначе выбор молча сбросился бы на чужую модель.
|
||||||
|
function fillModels(models, chat, embed) {
|
||||||
|
const opts = extra => ['<option value="">— не задана —</option>']
|
||||||
|
.concat((models.length ? models : []).map(m =>
|
||||||
|
'<option value="' + esc(m) + '">' + esc(m) + '</option>'))
|
||||||
|
.concat(extra && !models.includes(extra)
|
||||||
|
? ['<option value="' + esc(extra) + '">' + esc(extra) + ' (не установлена)</option>'] : [])
|
||||||
|
.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 = '<div class="card"><div class="addr'
|
||||||
|
+ (d.ok ? '' : ' taken') + '">' + esc(d.reply || '') + '</div></div>';
|
||||||
|
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:'Удалить'};
|
const LABEL = {put:'Положить', take:'Забрать', move:'Переместить', delete:'Удалить'};
|
||||||
document.querySelectorAll('.modes button').forEach(b => b.onclick = () => {
|
document.querySelectorAll('.modes button').forEach(b => b.onclick = () => {
|
||||||
|
|||||||
@@ -177,6 +177,45 @@ config.DB_FILE.write_text("{ битый json", encoding="utf-8")
|
|||||||
r.check("битая база не роняет", c.get("/api/tree").status_code == 200)
|
r.check("битая база не роняет", c.get("/api/tree").status_code == 200)
|
||||||
r.check("пустой ввод не роняет", "reply" in j("POST", "/api/say", text=" "))
|
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== Разбор фразы ==")
|
print("\n== Разбор фразы ==")
|
||||||
p = brain.fallback_parse("положил изоленту в коробку B2 на полке 1 в гараже")
|
p = brain.fallback_parse("положил изоленту в коробку B2 на полке 1 в гараже")
|
||||||
r.check("регулярки: put", p["action"] == "put" and store.norm_code(p["container"]) == "B2", p)
|
r.check("регулярки: put", p["action"] == "put" and store.norm_code(p["container"]) == "B2", p)
|
||||||
|
|||||||
Reference in New Issue
Block a user