Выбор мозгов: Ollama или веб-версия DeepSeek

Разбор свободных фраз, теги и подсказку по фотографии теперь может делать
не своя модель, а веб-версия DeepSeek. Своего клиента DeepSeek здесь нет
и не нужно: рядом живёт отдельный сервис deepseek-api, который держит
вкладку браузера и отдаёт наружу протокол Ollama. Для hlamingo вся разница
сводится к тому, по какому адресу спрашивать.

  HLAMINGO_ENGINE=ollama|deepseek
  HLAMINGO_DEEPSEEK_HOST=http://localhost:11500

Эмбеддинги в выбор не входят и всегда идут на OLLAMA_HOST: веб-чат векторов
не отдаёт, а подделать их нечем. Поэтому адрес Ollama нужен и в режиме
DeepSeek, иначе молча умирает поиск по смыслу.

Предохранитель в ollama.py теперь считается по каждому адресу отдельно.
Иначе лежачая Ollama гасила бы живой DeepSeek: неудача по одному адресу
на полминуты объявляла мёртвым и второй.

Проба адреса больше не подменяет config.OLLAMA_HOST на время проверки —
адрес передаётся явным параметром. Запросы идут в несколько потоков,
и подмена задевала чужие.

В настройках (вкладка "шестерёнка") появился выбор "Кто думает", поле
адреса DeepSeek и вторая кнопка проверки связи. Списка моделей теперь два:
для разбора фраз — у выбранного движка, для поиска по смыслу — у Ollama.

Замер на разборе фразы и генерации тегов: Ollama qwen3:8b 2-3 с,
DeepSeek 8-12 с плюс 15 с на подъём браузера при первом запросе.

Тесты: 115 успешно, 0 провалено.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Alex Cube
2026-08-26 12:48:30 +03:00
co-authored by Claude Opus 5
parent 1ae98b0b6f
commit 4d8175931b
7 changed files with 359 additions and 117 deletions
+10
View File
@@ -1,8 +1,18 @@
# Пример настроек. Скопируйте в .env и поправьте под себя. # Пример настроек. Скопируйте в .env и поправьте под себя.
# Файл .env в репозиторий не попадает. # Файл .env в репозиторий не попадает.
# Кто думает: ollama или deepseek.
# ollama — своя модель на своём компьютере, без интернета;
# deepseek — веб-версия DeepSeek через отдельную программу deepseek-api,
# которая держит вкладку браузера и притворяется Ollama.
# HLAMINGO_ENGINE=ollama
# Адрес сервиса deepseek-api. Нужен только при HLAMINGO_ENGINE=deepseek.
# HLAMINGO_DEEPSEEK_HOST=http://localhost:11500
# Адрес Ollama. Если Ollama на этом же компьютере — оставьте localhost. # Адрес Ollama. Если Ollama на этом же компьютере — оставьте localhost.
# Если на другой машине в локальной сети — укажите её адрес. # Если на другой машине в локальной сети — укажите её адрес.
# Нужна и в режиме deepseek: поиск по смыслу считает только она.
OLLAMA_HOST=http://localhost:11434 OLLAMA_HOST=http://localhost:11434
# Модель для разбора свободных фраз. Должна быть установлена в Ollama: # Модель для разбора свободных фраз. Должна быть установлена в Ollama:
+33 -7
View File
@@ -101,9 +101,10 @@ start.cmd запускает сервер и печатает адреса
## Настройки ## Настройки
**Проще всего — в браузере:** вкладка **⚙**. Там указывается адрес Ollama, **Проще всего — в браузере:** вкладка **⚙**. Там выбирается, кто думает
кнопка «Проверить связь» говорит, отвечает ли он и сколько там моделей, а модели (Ollama или веб-версия DeepSeek), указываются адреса, кнопка «Проверить связь»
выбираются из списка реально установленных. Сохранённое применяется сразу, говорит, отвечает ли он и сколько там моделей, а модели выбираются из списка
реально установленных. Сохранённое применяется сразу,
перезапуск не нужен. Если Ollama не отвечает, наверху появляется предупреждение перезапуск не нужен. Если Ollama не отвечает, наверху появляется предупреждение
со ссылкой прямо в настройки. со ссылкой прямо в настройки.
@@ -115,7 +116,9 @@ start.cmd запускает сервер и печатает адреса
| Переменная | По умолчанию | Зачем | | Переменная | По умолчанию | Зачем |
|---|---|---| |---|---|---|
| `HLAMINGO_ENGINE` | `ollama` | кто думает: `ollama` или `deepseek` |
| `OLLAMA_HOST` | `http://localhost:11434` | адрес Ollama | | `OLLAMA_HOST` | `http://localhost:11434` | адрес Ollama |
| `HLAMINGO_DEEPSEEK_HOST` | `http://localhost:11500` | адрес сервиса `deepseek-api` |
| `HLAMINGO_CHAT_MODEL` | `qwen3:8b` | разбор фраз и теги | | `HLAMINGO_CHAT_MODEL` | `qwen3:8b` | разбор фраз и теги |
| `HLAMINGO_EMBED_MODEL` | `bge-m3` | поиск по смыслу | | `HLAMINGO_EMBED_MODEL` | `bge-m3` | поиск по смыслу |
| `HLAMINGO_VISION_MODEL` | `gemma3:4b` | подсказка по фото; пусто — выключено | | `HLAMINGO_VISION_MODEL` | `gemma3:4b` | подсказка по фото; пусто — выключено |
@@ -123,13 +126,35 @@ start.cmd запускает сервер и печатает адреса
| `HLAMINGO_PORT` | `5000` | порт веб-интерфейса | | `HLAMINGO_PORT` | `5000` | порт веб-интерфейса |
| `HLAMINGO_DATA_DIR` | — | другая папка для данных и `.env`; используется тестами | | `HLAMINGO_DATA_DIR` | — | другая папка для данных и `.env`; используется тестами |
Из браузера правятся `OLLAMA_HOST`, две модели и путь для копий — по белому Из браузера правятся движок, оба адреса, две модели и путь для копий —
списку. по белому списку.
Произвольные ключи в `.env` через веб записать нельзя: этот файл читает Произвольные ключи в `.env` через веб записать нельзя: этот файл читает
приложение при запуске. Адрес проверяется на формат, имена моделей — на приложение при запуске. Адрес проверяется на формат, имена моделей — на
недопустимые символы, и если хоть одно значение неверно, не применяется недопустимые символы, и если хоть одно значение неверно, не применяется
ни одно (иначе половина настроек принята, половина отвергнута). ни одно (иначе половина настроек принята, половина отвергнута).
### Ollama или DeepSeek
Разбор свободных фраз, теги и подсказку по фотографии может делать не своя
модель, а веб-версия DeepSeek. У неё нет API — только вкладка в браузере,
поэтому рядом живёт отдельная программа [`deepseek-api`](../deepseek-api):
она держит эту вкладку и отдаёт наружу протокол Ollama. Для hlamingo вся
разница сводится к тому, по какому адресу спрашивать.
| | Ollama | DeepSeek (веб) |
|---|---|---|
| Где считается | ваш компьютер | серверы DeepSeek |
| Приватность | фразы никуда не уходят | фразы уходят наружу |
| Скорость | 2–3 с на вещь | десятки секунд: очередь и размышления |
| Что нужно | установленные модели | запущенный `deepseek-api` и браузер с логином |
| Работает без интернета | да | нет |
**Поиск по смыслу в этот выбор не входит.** Векторы считает только настоящая
Ollama — веб-чат их не отдаёт, поэтому её адрес нужен и в режиме DeepSeek.
Без неё останется поиск по названию, тегам и маркировке.
Переключение — вкладка **⚙**, «Кто думает». Сохранённое применяется сразу.
## Как пользоваться ## Как пользоваться
### Вкладка «Действие» — четыре кнопки ### Вкладка «Действие» — четыре кнопки
@@ -303,8 +328,9 @@ venv\Scripts\python.exe tools\reindex.py
``` ```
hlamingo\ hlamingo\
config.py адрес Ollama, модели, пути, порог поиска config.py движок, адреса, модели, пути, порог поиска
ollama.py клиент: chat / embeddings / проверка доступности ollama.py клиент: chat / embeddings / проверка доступности;
чат идёт на выбранный движок, эмбеддинги — всегда на Ollama
store.py места (иерархия), ёмкости, предметы; атомарная запись базы store.py места (иерархия), ёмкости, предметы; атомарная запись базы
search.py поиск по словам + смысловая догадка search.py поиск по словам + смысловая догадка
brain.py разбор фразы моделью, синонимы, фолбэк на регулярки brain.py разбор фразы моделью, синонимы, фолбэк на регулярки
+45 -23
View File
@@ -48,7 +48,10 @@ def favicon():
@app.route("/api/health") @app.route("/api/health")
def health(): def health():
return jsonify({"ollama": ollama.check(), "host": config.OLLAMA_HOST, # "ollama" здесь значит «мозги отвечают», кем бы они ни были: адрес берётся
# у того движка, который выбран.
return jsonify({"ollama": ollama.check(), "host": config.chat_host(),
"engine": config.ENGINE,
"model": config.CHAT_MODEL, "model": config.CHAT_MODEL,
# Пока ничего не записано и ИИ не отвечает — человек, скорее # Пока ничего не записано и ИИ не отвечает — человек, скорее
# всего, только что поставил приложение: покажем настройки. # всего, только что поставил приложение: покажем настройки.
@@ -58,17 +61,26 @@ def health():
# ============================================================ # ============================================================
# Настройки из браузера # Настройки из браузера
# #
# Чтобы после установки не возвращаться к консоли: адрес Ollama и модели # Чтобы после установки не возвращаться к консоли: движок, адреса и модели
# задаются с того же телефона, а модели выбираются из реально имеющихся. # задаются с того же телефона, а модели выбираются из реально имеющихся.
# ============================================================ # ============================================================
@app.route("/api/config") @app.route("/api/config")
def api_config(): def api_config():
# Списков моделей два: думать может DeepSeek, а векторы для поиска
# по смыслу в любом случае считает настоящая Ollama.
chat_models = ollama.models()
embed_models = (chat_models if config.ENGINE == "ollama"
else ollama.models(config.OLLAMA_HOST))
return jsonify({ return jsonify({
"engine": config.ENGINE,
"ollama_host": config.OLLAMA_HOST, "ollama_host": config.OLLAMA_HOST,
"deepseek_host": config.DEEPSEEK_HOST,
"chat_model": config.CHAT_MODEL, "chat_model": config.CHAT_MODEL,
"embed_model": config.EMBED_MODEL, "embed_model": config.EMBED_MODEL,
"available": ollama.check(), "available": ollama.check(),
"models": ollama.models(), "embed_available": bool(embed_models),
"models": chat_models,
"embed_models": embed_models,
"env_file": str(config.ENV_FILE), "env_file": str(config.ENV_FILE),
"backup": store.external_state(), "backup": store.external_state(),
}) })
@@ -150,26 +162,26 @@ def api_backup_now():
@app.route("/api/config/probe", methods=["POST"]) @app.route("/api/config/probe", methods=["POST"])
def api_config_probe(): def api_config_probe():
"""Проверяет адрес, ничего не сохраняя: что там за Ollama и какие модели.""" """Проверяет адрес, ничего не сохраняя: кто там и какие модели.
host = ((request.get_json(silent=True) or {}).get("ollama_host") or "").strip()
Адрес передаётся явным параметром, а не подменой настройки на время
проверки: запросы идут в несколько потоков, и подмена задевала бы чужие.
"""
data = request.get_json(silent=True) or {}
host = (data.get("host") or data.get("ollama_host") or "").strip().rstrip("/")
if not host: if not host:
return jsonify({"ok": False, "reply": "Адрес не указан."}), 400 return jsonify({"ok": False, "reply": "Адрес не указан."}), 400
was = config.OLLAMA_HOST ollama.reset_breaker(host) # спрашиваем заново, без памяти о прошлом
try: alive = ollama.check(host)
config.OLLAMA_HOST = host.rstrip("/") models = ollama.models(host) if alive else []
ollama.reset_breaker() # проверяем именно этот адрес, без памяти о прошлом ollama.reset_breaker(host)
alive = ollama.check()
models = ollama.models() if alive else []
finally:
config.OLLAMA_HOST = was # проба не должна менять настройки
ollama.reset_breaker()
if not alive: if not alive:
return jsonify({"ok": False, "models": [], return jsonify({"ok": False, "models": [],
"reply": "По адресу %s Ollama не отвечает." % host}) "reply": "По адресу %s никто не отвечает." % host})
return jsonify({"ok": True, "models": models, return jsonify({"ok": True, "models": models,
"reply": "Ollama отвечает, моделей: %d." % len(models)}) "reply": "Отвечает, моделей: %d." % len(models)})
@app.route("/api/config", methods=["POST"]) @app.route("/api/config", methods=["POST"])
@@ -177,7 +189,9 @@ def api_config_save():
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
try: try:
changed = config.save_settings({ changed = config.save_settings({
"HLAMINGO_ENGINE": data.get("engine"),
"OLLAMA_HOST": data.get("ollama_host"), "OLLAMA_HOST": data.get("ollama_host"),
"HLAMINGO_DEEPSEEK_HOST": data.get("deepseek_host"),
"HLAMINGO_CHAT_MODEL": data.get("chat_model"), "HLAMINGO_CHAT_MODEL": data.get("chat_model"),
"HLAMINGO_EMBED_MODEL": data.get("embed_model"), "HLAMINGO_EMBED_MODEL": data.get("embed_model"),
"HLAMINGO_BACKUP_DIR": data.get("backup_dir"), "HLAMINGO_BACKUP_DIR": data.get("backup_dir"),
@@ -187,12 +201,13 @@ def api_config_save():
if not changed: if not changed:
return jsonify({"ok": True, "reply": "Менять нечего."}) return jsonify({"ok": True, "reply": "Менять нечего."})
# Адрес поменяли — прошлая неудача больше ничего не значит, иначе новый # Адрес или движок поменяли — прошлая неудача больше ничего не значит,
# сервер полминуты считался бы мёртвым. # иначе новый сервер полминуты считался бы мёртвым.
ollama.reset_breaker() ollama.reset_breaker()
alive = ollama.check() alive = ollama.check()
reply = "Сохранил в .env. " + ("Ollama отвечает." if alive else who = "DeepSeek" if config.ENGINE == "deepseek" else "Ollama"
"Но Ollama по этому адресу молчит.") reply = "Сохранил в .env. " + ("%s отвечает." % who if alive else
"Но %s по адресу %s молчит." % (who, config.chat_host()))
return jsonify({"ok": True, "reply": reply, "available": alive, return jsonify({"ok": True, "reply": reply, "available": alive,
"changed": changed}) "changed": changed})
@@ -870,10 +885,17 @@ 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", config.OLLAMA_HOST, "доступен" if alive else "НЕ отвечает") logger.info("Мозги: %s, %s %s", config.ENGINE, config.chat_host(),
"отвечают" if alive else "НЕ отвечают")
if not alive: if not alive:
logger.warning("Без Ollama работает запись, обзор и поиск по названию. " logger.warning("Без них работает запись, обзор и поиск по названию. "
"Разбор фраз — упрощённый, поиск по смыслу отключён.") "Разбор фраз — упрощённый, теги не создаются.")
# Векторы для поиска по смыслу считает только настоящая Ollama: веб-чат
# DeepSeek их не отдаёт. При выборе DeepSeek это отдельный адрес и отдельная
# причина молчания поиска — о ней стоит сказать вслух.
if config.ENGINE == "deepseek" and not ollama.check(config.OLLAMA_HOST):
logger.warning("Ollama %s не отвечает — поиск по смыслу отключён.",
config.OLLAMA_HOST)
addresses = local_ips() addresses = local_ips()
if addresses: if addresses:
+45 -5
View File
@@ -48,7 +48,8 @@ ENV_FILE = (_ROOT_OVERRIDE / ".env") if _ROOT_OVERRIDE else (_PROJECT / ".env")
# Что разрешено менять из браузера. Белый список, а не любые ключи: в .env # Что разрешено менять из браузера. Белый список, а не любые ключи: в .env
# нельзя позволять писать произвольные строки — этот файл читает приложение # нельзя позволять писать произвольные строки — этот файл читает приложение
# при запуске. # при запуске.
EDITABLE = ("OLLAMA_HOST", "HLAMINGO_CHAT_MODEL", "HLAMINGO_EMBED_MODEL", EDITABLE = ("HLAMINGO_ENGINE", "OLLAMA_HOST", "HLAMINGO_DEEPSEEK_HOST",
"HLAMINGO_CHAT_MODEL", "HLAMINGO_EMBED_MODEL",
"HLAMINGO_BACKUP_DIR") "HLAMINGO_BACKUP_DIR")
_HOST_RE = None # ленивая компиляция: re нужен только при сохранении _HOST_RE = None # ленивая компиляция: re нужен только при сохранении
@@ -59,7 +60,11 @@ def _validate(key, raw):
import re as _re import re as _re
value = str(raw or "").strip() value = str(raw or "").strip()
if key == "OLLAMA_HOST": if key == "HLAMINGO_ENGINE":
if value not in ENGINES:
raise ValueError("Мозги бывают только %s." % " или ".join(ENGINES))
return value
if key in ("OLLAMA_HOST", "HLAMINGO_DEEPSEEK_HOST"):
if not _re.match(r"^https?://[\w.\-]+(:\d{1,5})?/?$", value): if not _re.match(r"^https?://[\w.\-]+(:\d{1,5})?/?$", value):
raise ValueError("Адрес Ollama должен быть вида http://хост:11434") raise ValueError("Адрес Ollama должен быть вида http://хост:11434")
return value.rstrip("/") return value.rstrip("/")
@@ -104,7 +109,9 @@ def save_settings(values):
def current(key): def current(key):
"""Текущее значение настройки по имени переменной окружения.""" """Текущее значение настройки по имени переменной окружения."""
return {"OLLAMA_HOST": OLLAMA_HOST, return {"HLAMINGO_ENGINE": ENGINE,
"OLLAMA_HOST": OLLAMA_HOST,
"HLAMINGO_DEEPSEEK_HOST": DEEPSEEK_HOST,
"HLAMINGO_CHAT_MODEL": CHAT_MODEL, "HLAMINGO_CHAT_MODEL": CHAT_MODEL,
"HLAMINGO_EMBED_MODEL": EMBED_MODEL, "HLAMINGO_EMBED_MODEL": EMBED_MODEL,
"HLAMINGO_BACKUP_DIR": BACKUP_DIR}.get(key, "") "HLAMINGO_BACKUP_DIR": BACKUP_DIR}.get(key, "")
@@ -112,9 +119,13 @@ def current(key):
def _apply(key, value): def _apply(key, value):
"""Обновляет значение в этом модуле. True, если оно действительно менялось.""" """Обновляет значение в этом модуле. True, если оно действительно менялось."""
global OLLAMA_HOST, CHAT_MODEL, EMBED_MODEL, BACKUP_DIR global ENGINE, OLLAMA_HOST, DEEPSEEK_HOST, CHAT_MODEL, EMBED_MODEL, BACKUP_DIR
if key == "OLLAMA_HOST" and value != OLLAMA_HOST: if key == "HLAMINGO_ENGINE" and value and value != ENGINE:
ENGINE = value
elif key == "OLLAMA_HOST" and value != OLLAMA_HOST:
OLLAMA_HOST = value OLLAMA_HOST = value
elif key == "HLAMINGO_DEEPSEEK_HOST" and value and value != DEEPSEEK_HOST:
DEEPSEEK_HOST = value
elif key == "HLAMINGO_CHAT_MODEL" and value and value != CHAT_MODEL: elif key == "HLAMINGO_CHAT_MODEL" and value and value != CHAT_MODEL:
CHAT_MODEL = value CHAT_MODEL = value
elif key == "HLAMINGO_EMBED_MODEL" and value and value != EMBED_MODEL: elif key == "HLAMINGO_EMBED_MODEL" and value and value != EMBED_MODEL:
@@ -201,7 +212,36 @@ EMBED_MODEL = os.environ.get("HLAMINGO_EMBED_MODEL") or "bge-m3"
EMBED_DOC_PREFIX = "" EMBED_DOC_PREFIX = ""
EMBED_QUERY_PREFIX = "" EMBED_QUERY_PREFIX = ""
# ============================================================
# Кто думает: Ollama или веб-DeepSeek
# ============================================================
# У DeepSeek нет ни ключа, ни эндпойнта — только вкладка в браузере. Поэтому
# рядом живёт отдельный сервис deepseek-api: он держит эту вкладку и отдаёт
# наружу протокол Ollama. Для hlamingo разница между движками сводится к тому,
# по какому адресу спрашивать, — своего клиента DeepSeek здесь нет и не нужно.
#
# Эмбеддинги в этот выбор не входят: веб-чат их не отдаёт, и поиск по смыслу
# в любом случае считает настоящая Ollama.
ENGINES = ("ollama", "deepseek")
ENGINE = (os.environ.get("HLAMINGO_ENGINE") or "ollama").strip().lower()
if ENGINE not in ENGINES:
ENGINE = "ollama"
DEEPSEEK_HOST = (os.environ.get("HLAMINGO_DEEPSEEK_HOST")
or "http://localhost:11500").rstrip("/")
def chat_host():
"""Куда идут разбор фраз, теги и распознавание фото."""
return DEEPSEEK_HOST if ENGINE == "deepseek" else OLLAMA_HOST
def chat_timeout():
"""DeepSeek думает в браузере и на длинном вопросе легко берёт минуту-две."""
return DEEPSEEK_TIMEOUT if ENGINE == "deepseek" else CHAT_TIMEOUT
CHAT_TIMEOUT = 120 # разбор одной фразы; больше не нужно CHAT_TIMEOUT = 120 # разбор одной фразы; больше не нужно
DEEPSEEK_TIMEOUT = 360 # веб-чат медленнее: своя очередь, свои размышления
EMBED_TIMEOUT = 30 EMBED_TIMEOUT = 30
MAX_OUTPUT_TOKENS = 512 # кап на ответ: разбор фразы — это короткий JSON MAX_OUTPUT_TOKENS = 512 # кап на ответ: разбор фразы — это короткий JSON
+70 -50
View File
@@ -2,6 +2,11 @@
Копия нужной части emilia/ollama.py, а не импорт из соседнего проекта: Эмилия Копия нужной части emilia/ollama.py, а не импорт из соседнего проекта: Эмилия
активно развивается, и общий модуль сделал бы hlamingo заложником её рефакторингов. активно развивается, и общий модуль сделал бы hlamingo заложником её рефакторингов.
Адресов два, а не один. Разбор фраз, теги и фото идут на `config.chat_host()`
это либо Ollama, либо сервис deepseek-api, который держит вкладку веб-DeepSeek
и отдаёт наружу тот же протокол. Эмбеддинги всегда идут на OLLAMA_HOST: веб-чат
их не считает, и подменять их нечем.
""" """
import io import io
@@ -40,86 +45,95 @@ CONNECT_TIMEOUT = 1.5 # столько ждём само соединени
DOWN_COOLDOWN = 30.0 # столько не тревожим сервер после неудачи DOWN_COOLDOWN = 30.0 # столько не тревожим сервер после неудачи
UP_CACHE = 5.0 # столько верим, что сервер жив, без повторной проверки UP_CACHE = 5.0 # столько верим, что сервер жив, без повторной проверки
_down_until = 0.0 # Предохранитель считается по каждому адресу отдельно: лежачая Ollama не должна
_up_until = 0.0 # гасить работающий deepseek-api и наоборот.
_down_until = {}
_up_until = {}
def _host_port(): def _host_port(host):
parsed = urllib.parse.urlparse(config.OLLAMA_HOST) parsed = urllib.parse.urlparse(host)
return parsed.hostname or "localhost", parsed.port or (443 if parsed.scheme == "https" else 80) return parsed.hostname or "localhost", parsed.port or (443 if parsed.scheme == "https" else 80)
def available(): def available(host=None):
"""Стоит ли вообще пытаться. Быстро и без долгих ожиданий.""" """Стоит ли вообще пытаться. Быстро и без долгих ожиданий."""
global _down_until, _up_until host = host or config.chat_host()
now = time.time() now = time.time()
if now < _up_until: if now < _up_until.get(host, 0.0):
return True return True
if now < _down_until: if now < _down_until.get(host, 0.0):
return False return False
try: try:
host, port = _host_port() socket.create_connection(_host_port(host), timeout=CONNECT_TIMEOUT).close()
socket.create_connection((host, port), timeout=CONNECT_TIMEOUT).close() _mark_up(host)
_up_until = now + UP_CACHE
_down_until = 0.0
return True return True
except Exception: except Exception:
_down_until = now + DOWN_COOLDOWN _mark_down(host)
_up_until = 0.0
return False return False
def _mark_down(): def _mark_down(host):
"""Запрос не удался — держим паузу, чтобы не ждать таймаут на каждом шаге.""" """Запрос не удался — держим паузу, чтобы не ждать таймаут на каждом шаге."""
global _down_until, _up_until _down_until[host] = time.time() + DOWN_COOLDOWN
_down_until = time.time() + DOWN_COOLDOWN _up_until.pop(host, None)
_up_until = 0.0
def _mark_up(): def _mark_up(host):
global _up_until _up_until[host] = time.time() + UP_CACHE
_up_until = time.time() + UP_CACHE _down_until.pop(host, None)
def reset_breaker(): def reset_breaker(host=None):
"""Сбросить паузу — например, когда адрес поменяли в настройках.""" """Сбросить паузу — например, когда адрес поменяли в настройках.
global _down_until, _up_until
_down_until = 0.0 Без аргумента забываем всё: движок могли переключить, и прошлые неудачи
_up_until = 0.0 ничего не говорят о новом адресе.
"""
if host is None:
_down_until.clear()
_up_until.clear()
else:
_down_until.pop(host, None)
_up_until.pop(host, None)
def check(): def check(host=None):
"""Доступен ли сервер. Отвечает быстро: это дёргает баннер в UI.""" """Доступен ли сервер. Отвечает быстро: это дёргает баннер в UI."""
if not available(): host = host or config.chat_host()
if not available(host):
return False return False
try: try:
req = urllib.request.Request(config.OLLAMA_HOST + "/api/tags") req = urllib.request.Request(host + "/api/tags")
with urllib.request.urlopen(req, timeout=3) as r: with urllib.request.urlopen(req, timeout=3) as r:
if r.status == 200: if r.status == 200:
_mark_up() _mark_up(host)
return True return True
except Exception: except Exception:
_mark_down() # Пока браузер с чатом не поднят, deepseek-api отвечает 503 — это тоже
# «недоступен», и сюда оно приходит исключением HTTPError.
_mark_down(host)
return False return False
def models(): def models(host=None):
"""Список установленных моделей (для диагностики).""" """Список моделей на этом адресе (для диагностики и выпадающих списков)."""
if not available(): host = host or config.chat_host()
if not available(host):
return [] return []
try: try:
req = urllib.request.Request(config.OLLAMA_HOST + "/api/tags") req = urllib.request.Request(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"))
_mark_up() _mark_up(host)
return [m.get("model", "") for m in data.get("models", [])] return [m.get("model", "") for m in data.get("models", [])]
except Exception as e: except Exception as e:
_mark_down() _mark_down(host)
logger.warning("Список моделей не получен: %s", e) logger.warning("Список моделей не получен: %s", e)
return [] return []
def chat(model, messages, timeout=CHAT_TIMEOUT, num_predict=MAX_OUTPUT_TOKENS, def chat(model, messages, timeout=None, num_predict=MAX_OUTPUT_TOKENS,
think=False): think=False):
"""Один запрос к /api/chat. Возвращает content или None при любой беде. """Один запрос к /api/chat. Возвращает content или None при любой беде.
@@ -131,6 +145,8 @@ def chat(model, messages, timeout=CHAT_TIMEOUT, num_predict=MAX_OUTPUT_TOKENS,
внутренний монолог и возвращают пустой content проверено на qwen3:8b. внутренний монолог и возвращают пустой content проверено на qwen3:8b.
Модели без такого режима поле просто игнорируют. Модели без такого режима поле просто игнорируют.
""" """
host = config.chat_host()
timeout = timeout or config.chat_timeout()
payload = { payload = {
"model": model, "model": model,
"messages": messages, "messages": messages,
@@ -138,19 +154,19 @@ def chat(model, messages, timeout=CHAT_TIMEOUT, num_predict=MAX_OUTPUT_TOKENS,
"think": think, "think": think,
"options": {"num_predict": num_predict, "temperature": 0}, "options": {"num_predict": num_predict, "temperature": 0},
} }
if not available(): if not available(host):
return None # сервер лежит: не ждём таймаут на каждой записи return None # сервер лежит: не ждём таймаут на каждой записи
data = json.dumps(payload).encode("utf-8") data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request( req = urllib.request.Request(
config.OLLAMA_HOST + "/api/chat", data=data, host + "/api/chat", data=data,
headers={"Content-Type": "application/json"}, headers={"Content-Type": "application/json"},
) )
try: try:
with urllib.request.urlopen(req, timeout=timeout) as r: with urllib.request.urlopen(req, timeout=timeout) as r:
resp = json.loads(r.read().decode("utf-8")) resp = json.loads(r.read().decode("utf-8"))
_mark_up() _mark_up(host)
except Exception as e: except Exception as e:
_mark_down() _mark_down(host)
logger.warning("Запрос к модели %s не удался: %s", model, e) logger.warning("Запрос к модели %s не удался: %s", model, e)
return None return None
@@ -176,8 +192,9 @@ def look(image_bytes, prompt, model=None, budget=None, timeout=180):
Картинка уменьшается перед отправкой: изображение разворачивается в токены, Картинка уменьшается перед отправкой: изображение разворачивается в токены,
и снимок с телефона стоит их тысячами без пользы для чтения крупных надписей. и снимок с телефона стоит их тысячами без пользы для чтения крупных надписей.
""" """
host = config.chat_host()
model = model or config.VISION_MODEL model = model or config.VISION_MODEL
if not model or not image_bytes or not available(): if not model or not image_bytes or not available(host):
return None return None
small = _shrink(image_bytes, config.VISION_MAX_SIDE) small = _shrink(image_bytes, config.VISION_MAX_SIDE)
@@ -189,17 +206,17 @@ def look(image_bytes, prompt, model=None, budget=None, timeout=180):
"options": {"num_predict": budget or config.VISION_BUDGET, "temperature": 0}, "options": {"num_predict": budget or config.VISION_BUDGET, "temperature": 0},
} }
req = urllib.request.Request( req = urllib.request.Request(
config.OLLAMA_HOST + "/api/chat", host + "/api/chat",
data=json.dumps(payload).encode("utf-8"), data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"}, headers={"Content-Type": "application/json"},
) )
try: try:
with urllib.request.urlopen(req, timeout=timeout) as r: with urllib.request.urlopen(req, timeout=timeout) as r:
resp = json.loads(r.read().decode("utf-8")) resp = json.loads(r.read().decode("utf-8"))
_mark_up() _mark_up(host)
return ((resp.get("message") or {}).get("content") or "").strip() return ((resp.get("message") or {}).get("content") or "").strip()
except Exception as e: except Exception as e:
_mark_down() _mark_down(host)
logger.warning("Не удалось распознать фото моделью %s: %s", model, e) logger.warning("Не удалось распознать фото моделью %s: %s", model, e)
return None return None
@@ -234,20 +251,23 @@ def embed(text, is_query=False):
text = (text or "").strip() text = (text or "").strip()
if not text: if not text:
return None return None
if not available(): # Эмбеддинги всегда у настоящей Ollama, даже когда думает DeepSeek:
# веб-чат векторов не отдаёт.
host = config.OLLAMA_HOST
if not available(host):
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": config.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(
config.OLLAMA_HOST + "/api/embeddings", data=data, host + "/api/embeddings", data=data,
headers={"Content-Type": "application/json"}, headers={"Content-Type": "application/json"},
) )
try: try:
with urllib.request.urlopen(req, timeout=EMBED_TIMEOUT) as r: with urllib.request.urlopen(req, timeout=EMBED_TIMEOUT) as r:
vector = json.loads(r.read().decode("utf-8")).get("embedding") vector = json.loads(r.read().decode("utf-8")).get("embedding")
_mark_up() _mark_up(host)
return vector return vector
except Exception as e: except Exception as e:
_mark_down() _mark_down(host)
logger.warning("Эмбеддинг не получен: %s", e) logger.warning("Эмбеддинг не получен: %s", e)
return None return None
+103 -31
View File
@@ -239,27 +239,50 @@
<!-- ================= Настройки ================= --> <!-- ================= Настройки ================= -->
<section class="tab" id="tab-setup"> <section class="tab" id="tab-setup">
<p class="hint" id="setupIntro">Здесь настраивается связь с Ollama — это «мозги» <p class="hint" id="setupIntro">Здесь настраиваются «мозги» помощника. Без них
помощника. Без неё всё остальное работает, но не будет разбора фраз, всё остальное работает, но не будет разбора свободных фраз, автоматических
автоматических тегов и поиска по смыслу.</p> тегов и подсказок по фотографии.</p>
<label>Кто думает</label>
<select id="cfgEngine">
<option value="ollama">Ollama — своя модель на своём компьютере</option>
<option value="deepseek">DeepSeek — веб-версия через браузер</option>
</select>
<p class="hint">Ollama работает без интернета и ни о чём никому не сообщает.
DeepSeek умнее, но отвечает медленнее, требует открытого браузера
с выполненным входом, и ваши фразы уходят на его серверы.</p>
<div id="deepseekRow" hidden>
<label>Адрес сервиса DeepSeek</label>
<input type="text" id="cfgDeepseekHost" placeholder="http://localhost:11500"
autocomplete="off" inputmode="url">
<p class="hint">Это отдельная программа <code>deepseek-api</code>: она держит
вкладку с чатом DeepSeek и притворяется Ollama. Её нужно запустить
(<code>start.cmd</code>) — сама она не поднимется.</p>
<div class="row mt">
<button class="act ghost" id="cfgProbeDeepseek">Проверить DeepSeek</button>
</div>
<div id="cfgProbeDeepseekResult"></div>
</div>
<label>Адрес Ollama</label> <label>Адрес Ollama</label>
<input type="text" id="cfgHost" placeholder="http://192.168.1.50:11434" autocomplete="off" <input type="text" id="cfgHost" placeholder="http://192.168.1.50:11434" autocomplete="off"
inputmode="url"> inputmode="url">
<p class="hint">Если Ollama на этом же компьютере — <code>http://localhost:11434</code>. <p class="hint" id="cfgHostHint">Если Ollama на этом же компьютере —
Если на другой машине — её адрес в локальной сети.</p> <code>http://localhost:11434</code>. Если на другой машине — её адрес
в локальной сети.</p>
<div class="row mt"> <div class="row mt">
<button class="act ghost" id="cfgProbe">Проверить связь</button> <button class="act ghost" id="cfgProbe">Проверить связь</button>
</div> </div>
<div id="cfgProbeResult"></div> <div id="cfgProbeResult"></div>
<label>Модель для разбора фраз</label> <label id="cfgChatLabel">Модель для разбора фраз</label>
<select id="cfgChat"></select> <select id="cfgChat"></select>
<label>Модель для поиска по смыслу</label> <label>Модель для поиска по смыслу</label>
<select id="cfgEmbed"></select> <select id="cfgEmbed"></select>
<p class="hint">Список заполняется моделями, которые реально установлены. <p class="hint">Список заполняется моделями, которые реально есть.
Нужных нет — на машине с Ollama: <code>ollama pull qwen2.5:7b</code> Нужных нет — на машине с Ollama: <code>ollama pull qwen3:8b</code>
и <code>ollama pull nomic-embed-text</code>.</p> и <code>ollama pull bge-m3</code>.</p>
<div class="mt"><button class="act" id="cfgSave">Сохранить</button></div> <div class="mt"><button class="act" id="cfgSave">Сохранить</button></div>
<p class="hint" id="cfgWhere"></p> <p class="hint" id="cfgWhere"></p>
@@ -346,10 +369,12 @@ api('/api/health').then(h => {
if (h.ollama) return; if (h.ollama) return;
const b = $('#banner'); const b = $('#banner');
b.style.display = 'block'; b.style.display = 'block';
const who = h.engine === 'deepseek' ? 'сервис DeepSeek' : 'Ollama';
b.textContent = h.fresh b.textContent = h.fresh
? 'Похоже, помощник только установлен, а Ollama по адресу ' + h.host + ' не отвечает. ' ? 'Похоже, помощник только установлен, а ' + who + ' по адресу ' + h.host
: 'ИИ недоступен (' + h.host + '). Кнопки работают полностью; не будет только ' + ' не отвечает. '
+ 'тегов и разбора свободных фраз. '; : 'ИИ недоступен: ' + who + ' по адресу ' + h.host + ' молчит. Кнопки работают '
+ 'полностью; не будет только тегов и разбора свободных фраз. ';
const link = document.createElement('a'); const link = document.createElement('a');
link.href = '#'; link.href = '#';
link.textContent = 'Настроить →'; link.textContent = 'Настроить →';
@@ -362,13 +387,21 @@ api('/api/health').then(h => {
// ---------- настройки ---------- // ---------- настройки ----------
async function loadConfig() { async function loadConfig() {
const d = await api('/api/config'); const d = await api('/api/config');
$('#cfgEngine').value = d.engine || 'ollama';
$('#cfgHost').value = d.ollama_host || ''; $('#cfgHost').value = d.ollama_host || '';
fillModels(d.models || [], d.chat_model, d.embed_model); $('#cfgDeepseekHost').value = d.deepseek_host || '';
showEngine();
fillModels(d.models || [], d.embed_models || [], d.chat_model, d.embed_model);
$('#cfgWhere').textContent = 'Настройки хранятся в файле ' + d.env_file $('#cfgWhere').textContent = 'Настройки хранятся в файле ' + d.env_file
+ ' — он не попадает в репозиторий.'; + ' — он не попадает в репозиторий.';
if (!d.available) { // Ollama нужна всегда: даже когда думает DeepSeek, векторы для поиска
$('#cfgProbeResult').innerHTML = // по смыслу считает она — веб-чат их не отдаёт.
'<div class="card"><div class="addr taken">Ollama по этому адресу не отвечает.</div></div>'; $('#cfgProbeResult').innerHTML = d.embed_available ? ''
: '<div class="card"><div class="addr taken">Ollama по этому адресу не отвечает.</div></div>';
if (d.engine === 'deepseek') {
$('#cfgProbeDeepseekResult').innerHTML = d.available ? ''
: '<div class="card"><div class="addr taken">Сервис DeepSeek не отвечает. '
+ 'Запущен ли deepseek-api и открыт ли браузер с чатом?</div></div>';
} }
showBackupState(d.backup || {}); showBackupState(d.backup || {});
} }
@@ -460,30 +493,67 @@ $('#archiveInput').onchange = async () => {
// Модель выбирается из установленных; текущее значение показываем даже если // Модель выбирается из установленных; текущее значение показываем даже если
// его нет в списке — иначе выбор молча сбросился бы на чужую модель. // его нет в списке — иначе выбор молча сбросился бы на чужую модель.
function fillModels(models, chat, embed) { // Списков два: разбор фраз может считать DeepSeek, а векторы для поиска
const opts = extra => ['<option value="">— не задана</option>'] // по смыслу — всегда настоящая Ollama.
.concat((models.length ? models : []).map(m => function fillModels(chatModels, embedModels, chat, embed) {
'<option value="' + esc(m) + '">' + esc(m) + '</option>')) const opts = (list, extra) => ['<option value="">— не задана —</option>']
.concat(extra && !models.includes(extra) .concat(list.map(m => '<option value="' + esc(m) + '">' + esc(m) + '</option>'))
.concat(extra && !list.includes(extra)
? ['<option value="' + esc(extra) + '">' + esc(extra) + ' (не установлена)</option>'] : []) ? ['<option value="' + esc(extra) + '">' + esc(extra) + ' (не установлена)</option>'] : [])
.join(''); .join('');
$('#cfgChat').innerHTML = opts(chat); $('#cfgChat').innerHTML = opts(chatModels, chat);
$('#cfgEmbed').innerHTML = opts(embed); $('#cfgEmbed').innerHTML = opts(embedModels, embed);
$('#cfgChat').value = chat || ''; $('#cfgChat').value = chat || '';
$('#cfgEmbed').value = embed || ''; $('#cfgEmbed').value = embed || '';
} }
$('#cfgProbe').onclick = async () => { // Поле адреса DeepSeek показываем только когда он выбран, а подпись у Ollama
const btn = $('#cfgProbe'); // меняем: в режиме DeepSeek она нужна уже не для мыслей, а только для поиска.
btn.disabled = true; btn.textContent = 'Проверяю…'; function showEngine() {
const ds = $('#cfgEngine').value === 'deepseek';
$('#deepseekRow').hidden = !ds;
$('#cfgHostHint').innerHTML = ds
? 'Нужна и в этом режиме: поиск по смыслу считает только Ollama — веб-чат '
+ 'векторов не отдаёт. Если её нет, останется поиск по названию и тегам.'
: 'Если Ollama на этом же компьютере — <code>http://localhost:11434</code>. '
+ 'Если на другой машине — её адрес в локальной сети.';
$('#cfgChatLabel').textContent = ds
? 'Чат DeepSeek для разбора фраз' : 'Модель для разбора фраз';
}
$('#cfgEngine').onchange = showEngine;
// Обе кнопки проверки делают одно и то же, отличаются только адресом и тем,
// куда класть ответ.
async function probeHost(btn, input, box, label) {
const b = $(btn), was = $(btn).textContent;
b.disabled = true; b.textContent = 'Проверяю…';
try { try {
const d = await api('/api/config/probe', {method:'POST', const d = await api('/api/config/probe', {method:'POST',
headers:{'Content-Type':'application/json'}, headers:{'Content-Type':'application/json'},
body: JSON.stringify({ollama_host: $('#cfgHost').value.trim()})}); body: JSON.stringify({host: $(input).value.trim()})});
$('#cfgProbeResult').innerHTML = '<div class="card"><div class="addr' $(box).innerHTML = '<div class="card"><div class="addr'
+ (d.ok ? '' : ' taken') + '">' + esc(d.reply || '') + '</div></div>'; + (d.ok ? '' : ' taken') + '">' + esc(d.reply || '') + '</div></div>';
if (d.ok) fillModels(d.models || [], $('#cfgChat').value, $('#cfgEmbed').value); return d;
} finally { btn.disabled = false; btn.textContent = 'Проверить связь'; } } finally { b.disabled = false; b.textContent = was; }
}
$('#cfgProbe').onclick = async () => {
const d = await probeHost('#cfgProbe', '#cfgHost', '#cfgProbeResult');
if (!d.ok) return;
// Модели Ollama — всегда для поиска по смыслу; для разбора фраз они годятся
// только когда думает она же.
const ds = $('#cfgEngine').value === 'deepseek';
fillModels(ds ? [...$('#cfgChat').options].map(o => o.value).filter(Boolean)
: (d.models || []),
d.models || [], $('#cfgChat').value, $('#cfgEmbed').value);
};
$('#cfgProbeDeepseek').onclick = async () => {
const d = await probeHost('#cfgProbeDeepseek', '#cfgDeepseekHost',
'#cfgProbeDeepseekResult');
if (d.ok) fillModels(d.models || [],
[...$('#cfgEmbed').options].map(o => o.value).filter(Boolean),
$('#cfgChat').value, $('#cfgEmbed').value);
}; };
$('#cfgSave').onclick = async () => { $('#cfgSave').onclick = async () => {
@@ -492,7 +562,9 @@ $('#cfgSave').onclick = async () => {
try { try {
const d = await api('/api/config', {method:'POST', const d = await api('/api/config', {method:'POST',
headers:{'Content-Type':'application/json'}, headers:{'Content-Type':'application/json'},
body: JSON.stringify({ollama_host: $('#cfgHost').value.trim(), body: JSON.stringify({engine: $('#cfgEngine').value,
ollama_host: $('#cfgHost').value.trim(),
deepseek_host: $('#cfgDeepseekHost').value.trim(),
chat_model: $('#cfgChat').value, chat_model: $('#cfgChat').value,
embed_model: $('#cfgEmbed').value, embed_model: $('#cfgEmbed').value,
backup_dir: $('#cfgBackupDir').value.trim()})}); backup_dir: $('#cfgBackupDir').value.trim()})});
+53 -1
View File
@@ -263,7 +263,7 @@ try:
r.check("поиск работает без ИИ", d["items"] and _time.time() - t0 < 3, d) r.check("поиск работает без ИИ", d["items"] and _time.time() - t0 < 3, d)
r.check("смена адреса сбрасывает паузу", r.check("смена адреса сбрасывает паузу",
(ollama.reset_breaker() or True) and ollama._down_until == 0) (ollama.reset_breaker() or True) and ollama._down_until == {})
finally: finally:
config.OLLAMA_HOST = _was_host config.OLLAMA_HOST = _was_host
ollama.reset_breaker() ollama.reset_breaker()
@@ -393,6 +393,58 @@ r.check("инъекция в имя модели — 400",
c.post("/api/config", c.post("/api/config",
json={"ollama_host": "http://127.0.0.1:11434", json={"ollama_host": "http://127.0.0.1:11434",
"chat_model": "модель\nOLLAMA_HOST=http://зло"}).status_code == 400) "chat_model": "модель\nOLLAMA_HOST=http://зло"}).status_code == 400)
# ---- выбор мозгов: Ollama или веб-DeepSeek ----
# Проверяем не сам DeepSeek (для него нужен браузер с логином), а развилку:
# куда уходит разбор фраз и куда — эмбеддинги. Перепутать их значит тихо
# сломать поиск по смыслу, и это не видно, пока не начнёшь искать.
was_engine = config.ENGINE
DS = "http://127.0.0.1:11500"
try:
d = j("POST", "/api/config", engine="deepseek", deepseek_host=DS)
r.check("движок переключился", d.get("ok") and config.ENGINE == "deepseek", d)
r.check("думает DeepSeek", config.chat_host() == DS, config.chat_host())
r.check("здоровье докладывает движок",
c.get("/api/health").get_json()["engine"] == "deepseek")
r.check("ждём DeepSeek дольше", config.chat_timeout() > config.CHAT_TIMEOUT,
config.chat_timeout())
# Куда реально уходят запросы. Предохранитель и сеть отключаем: важно не
# то, ответил ли кто-то, а по какому адресу постучались.
tried = []
def _spy(req, **kw):
tried.append(req.full_url)
raise OSError("в тесте наружу не ходим")
real_urlopen = ollama.urllib.request.urlopen
real_available = ollama.available
ollama.urllib.request.urlopen = _spy
ollama.available = lambda host=None: True
try:
ollama.embed("гвозди")
ollama.chat(config.CHAT_MODEL, [{"role": "user", "content": "привет"}])
finally:
ollama.urllib.request.urlopen = real_urlopen
ollama.available = real_available
ollama.reset_breaker()
r.check("эмбеддинги идут в Ollama",
any(u.startswith(config.OLLAMA_HOST) and "embed" in u for u in tried), tried)
r.check("разбор фраз идёт в DeepSeek",
any(u.startswith(DS) and u.endswith("/api/chat") for u in tried), tried)
r.check("чужой движок — 400",
c.post("/api/config", json={"engine": "chatgpt"}).status_code == 400)
r.check("мусорный адрес DeepSeek — 400",
c.post("/api/config", json={"deepseek_host": "не адрес"}).status_code == 400)
r.check("проба берёт адрес из запроса, а не из настроек",
c.post("/api/config/probe",
json={"host": "http://192.0.2.1:11434"}).get_json()["ok"] is False
and config.OLLAMA_HOST != "http://192.0.2.1:11434")
finally:
j("POST", "/api/config", engine=was_engine)
ollama.reset_breaker()
r.check("движок вернулся", config.ENGINE == was_engine, config.ENGINE)
# Отказ должен быть полным: адрес из того же запроса применяться не должен, # Отказ должен быть полным: адрес из того же запроса применяться не должен,
# иначе половина настроек принята, а половина отвергнута. # иначе половина настроек принята, а половина отвергнута.
r.check("при отказе НИЧЕГО не применилось", r.check("при отказе НИЧЕГО не применилось",