Настройка из браузера: вкладка ⚙ вместо правки файла

Инсталлятор спрашивает адрес Ollama в консоли, но это можно пропустить
и задать всё с телефона: адрес, модель для разбора фраз, модель для поиска
по смыслу. «Проверить связь» показывает, отвечает ли Ollama и сколько там
моделей; сами модели выбираются из реально установленных, а не вводятся
руками. Если Ollama молчит, предупреждение наверху ведёт прямо в настройки.

Настройки применяются сразу: модули читают их через config.X, а не забирают
копию при импорте, поэтому перезапуск не нужен.

Из веба правятся только OLLAMA_HOST и две модели — по белому списку, потому
что .env читает приложение при запуске. Адрес проверяется на формат, имена
моделей — на недопустимые символы.

Попутно исправлены два дефекта, найденных тестами:
- .env в песочнице теперь тоже свой; до этого прогон тестов перезаписывал
  рабочий файл настроек;
- значения проверяются все до применения: раньше неверное имя модели
  оставляло уже подменённый адрес Ollama.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 14:40:15 +03:00
co-authored by Claude Opus 5
parent 17b7d49d2f
commit 4f298ac203
7 changed files with 353 additions and 31 deletions
+18 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 = () => {
+39
View File
@@ -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)