Настройка из браузера: вкладка ⚙ вместо правки файла
Инсталлятор спрашивает адрес 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_EMBED_MODEL` | `nomic-embed-text:latest` | поиск по смыслу |
|
||||
| `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 . 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 работает запись, обзор и поиск по названию. "
|
||||
"Разбор фраз — упрощённый, поиск по смыслу отключён.")
|
||||
|
||||
+5
-3
@@ -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},
|
||||
|
||||
+99
-4
@@ -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
|
||||
|
||||
# ============================================================
|
||||
# Веб
|
||||
|
||||
+10
-7
@@ -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:
|
||||
|
||||
+113
-10
@@ -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 @@
|
||||
<button class="on" data-tab="do">Действие</button>
|
||||
<button data-tab="find">Найти</button>
|
||||
<button data-tab="tree">Обзор</button>
|
||||
<button data-tab="setup" class="narrowTab">⚙</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -213,6 +216,34 @@
|
||||
<div id="findCards"></div>
|
||||
</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">
|
||||
<button class="act ghost" id="treeBtn">Обновить</button>
|
||||
@@ -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 =
|
||||
'<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:'Удалить'};
|
||||
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("пустой ввод не роняет", "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)
|
||||
|
||||
Reference in New Issue
Block a user