Выбор мозгов: 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 в репозиторий не попадает.
# Кто думает: 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.
# Если на другой машине в локальной сети — укажите её адрес.
# Нужна и в режиме deepseek: поиск по смыслу считает только она.
OLLAMA_HOST=http://localhost:11434
# Модель для разбора свободных фраз. Должна быть установлена в Ollama:
+33 -7
View File
@@ -101,9 +101,10 @@ start.cmd запускает сервер и печатает адреса
## Настройки
**Проще всего — в браузере:** вкладка **⚙**. Там указывается адрес Ollama,
кнопка «Проверить связь» говорит, отвечает ли он и сколько там моделей, а модели
выбираются из списка реально установленных. Сохранённое применяется сразу,
**Проще всего — в браузере:** вкладка **⚙**. Там выбирается, кто думает
(Ollama или веб-версия DeepSeek), указываются адреса, кнопка «Проверить связь»
говорит, отвечает ли он и сколько там моделей, а модели выбираются из списка
реально установленных. Сохранённое применяется сразу,
перезапуск не нужен. Если Ollama не отвечает, наверху появляется предупреждение
со ссылкой прямо в настройки.
@@ -115,7 +116,9 @@ start.cmd запускает сервер и печатает адреса
| Переменная | По умолчанию | Зачем |
|---|---|---|
| `HLAMINGO_ENGINE` | `ollama` | кто думает: `ollama` или `deepseek` |
| `OLLAMA_HOST` | `http://localhost:11434` | адрес Ollama |
| `HLAMINGO_DEEPSEEK_HOST` | `http://localhost:11500` | адрес сервиса `deepseek-api` |
| `HLAMINGO_CHAT_MODEL` | `qwen3:8b` | разбор фраз и теги |
| `HLAMINGO_EMBED_MODEL` | `bge-m3` | поиск по смыслу |
| `HLAMINGO_VISION_MODEL` | `gemma3:4b` | подсказка по фото; пусто — выключено |
@@ -123,13 +126,35 @@ start.cmd запускает сервер и печатает адреса
| `HLAMINGO_PORT` | `5000` | порт веб-интерфейса |
| `HLAMINGO_DATA_DIR` | — | другая папка для данных и `.env`; используется тестами |
Из браузера правятся `OLLAMA_HOST`, две модели и путь для копий — по белому
списку.
Из браузера правятся движок, оба адреса, две модели и путь для копий —
по белому списку.
Произвольные ключи в `.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\
config.py адрес Ollama, модели, пути, порог поиска
ollama.py клиент: chat / embeddings / проверка доступности
config.py движок, адреса, модели, пути, порог поиска
ollama.py клиент: chat / embeddings / проверка доступности;
чат идёт на выбранный движок, эмбеддинги — всегда на Ollama
store.py места (иерархия), ёмкости, предметы; атомарная запись базы
search.py поиск по словам + смысловая догадка
brain.py разбор фразы моделью, синонимы, фолбэк на регулярки
+45 -23
View File
@@ -48,7 +48,10 @@ def favicon():
@app.route("/api/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,
# Пока ничего не записано и ИИ не отвечает — человек, скорее
# всего, только что поставил приложение: покажем настройки.
@@ -58,17 +61,26 @@ def health():
# ============================================================
# Настройки из браузера
#
# Чтобы после установки не возвращаться к консоли: адрес Ollama и модели
# Чтобы после установки не возвращаться к консоли: движок, адреса и модели
# задаются с того же телефона, а модели выбираются из реально имеющихся.
# ============================================================
@app.route("/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({
"engine": config.ENGINE,
"ollama_host": config.OLLAMA_HOST,
"deepseek_host": config.DEEPSEEK_HOST,
"chat_model": config.CHAT_MODEL,
"embed_model": config.EMBED_MODEL,
"available": ollama.check(),
"models": ollama.models(),
"embed_available": bool(embed_models),
"models": chat_models,
"embed_models": embed_models,
"env_file": str(config.ENV_FILE),
"backup": store.external_state(),
})
@@ -150,26 +162,26 @@ def api_backup_now():
@app.route("/api/config/probe", methods=["POST"])
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:
return jsonify({"ok": False, "reply": "Адрес не указан."}), 400
was = config.OLLAMA_HOST
try:
config.OLLAMA_HOST = host.rstrip("/")
ollama.reset_breaker() # проверяем именно этот адрес, без памяти о прошлом
alive = ollama.check()
models = ollama.models() if alive else []
finally:
config.OLLAMA_HOST = was # проба не должна менять настройки
ollama.reset_breaker()
ollama.reset_breaker(host) # спрашиваем заново, без памяти о прошлом
alive = ollama.check(host)
models = ollama.models(host) if alive else []
ollama.reset_breaker(host)
if not alive:
return jsonify({"ok": False, "models": [],
"reply": "По адресу %s Ollama не отвечает." % host})
"reply": "По адресу %s никто не отвечает." % host})
return jsonify({"ok": True, "models": models,
"reply": "Ollama отвечает, моделей: %d." % len(models)})
"reply": "Отвечает, моделей: %d." % len(models)})
@app.route("/api/config", methods=["POST"])
@@ -177,7 +189,9 @@ def api_config_save():
data = request.get_json(silent=True) or {}
try:
changed = config.save_settings({
"HLAMINGO_ENGINE": data.get("engine"),
"OLLAMA_HOST": data.get("ollama_host"),
"HLAMINGO_DEEPSEEK_HOST": data.get("deepseek_host"),
"HLAMINGO_CHAT_MODEL": data.get("chat_model"),
"HLAMINGO_EMBED_MODEL": data.get("embed_model"),
"HLAMINGO_BACKUP_DIR": data.get("backup_dir"),
@@ -187,12 +201,13 @@ def api_config_save():
if not changed:
return jsonify({"ok": True, "reply": "Менять нечего."})
# Адрес поменяли — прошлая неудача больше ничего не значит, иначе новый
# сервер полминуты считался бы мёртвым.
# Адрес или движок поменяли — прошлая неудача больше ничего не значит,
# иначе новый сервер полминуты считался бы мёртвым.
ollama.reset_breaker()
alive = ollama.check()
reply = "Сохранил в .env. " + ("Ollama отвечает." if alive else
"Но Ollama по этому адресу молчит.")
who = "DeepSeek" if config.ENGINE == "deepseek" else "Ollama"
reply = "Сохранил в .env. " + ("%s отвечает." % who if alive else
"Но %s по адресу %s молчит." % (who, config.chat_host()))
return jsonify({"ok": True, "reply": reply, "available": alive,
"changed": changed})
@@ -870,10 +885,17 @@ def main():
PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
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:
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()
if addresses:
+45 -5
View File
@@ -48,7 +48,8 @@ ENV_FILE = (_ROOT_OVERRIDE / ".env") if _ROOT_OVERRIDE else (_PROJECT / ".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")
_HOST_RE = None # ленивая компиляция: re нужен только при сохранении
@@ -59,7 +60,11 @@ def _validate(key, raw):
import re as _re
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):
raise ValueError("Адрес Ollama должен быть вида http://хост:11434")
return value.rstrip("/")
@@ -104,7 +109,9 @@ def save_settings(values):
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_EMBED_MODEL": EMBED_MODEL,
"HLAMINGO_BACKUP_DIR": BACKUP_DIR}.get(key, "")
@@ -112,9 +119,13 @@ def current(key):
def _apply(key, value):
"""Обновляет значение в этом модуле. True, если оно действительно менялось."""
global OLLAMA_HOST, CHAT_MODEL, EMBED_MODEL, BACKUP_DIR
if key == "OLLAMA_HOST" and value != OLLAMA_HOST:
global ENGINE, OLLAMA_HOST, DEEPSEEK_HOST, CHAT_MODEL, EMBED_MODEL, BACKUP_DIR
if key == "HLAMINGO_ENGINE" and value and value != ENGINE:
ENGINE = value
elif key == "OLLAMA_HOST" and value != OLLAMA_HOST:
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:
CHAT_MODEL = value
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_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 # разбор одной фразы; больше не нужно
DEEPSEEK_TIMEOUT = 360 # веб-чат медленнее: своя очередь, свои размышления
EMBED_TIMEOUT = 30
MAX_OUTPUT_TOKENS = 512 # кап на ответ: разбор фразы — это короткий JSON
+70 -50
View File
@@ -2,6 +2,11 @@
Копия нужной части emilia/ollama.py, а не импорт из соседнего проекта: Эмилия
активно развивается, и общий модуль сделал бы hlamingo заложником её рефакторингов.
Адресов два, а не один. Разбор фраз, теги и фото идут на `config.chat_host()`
это либо Ollama, либо сервис deepseek-api, который держит вкладку веб-DeepSeek
и отдаёт наружу тот же протокол. Эмбеддинги всегда идут на OLLAMA_HOST: веб-чат
их не считает, и подменять их нечем.
"""
import io
@@ -40,86 +45,95 @@ CONNECT_TIMEOUT = 1.5 # столько ждём само соединени
DOWN_COOLDOWN = 30.0 # столько не тревожим сервер после неудачи
UP_CACHE = 5.0 # столько верим, что сервер жив, без повторной проверки
_down_until = 0.0
_up_until = 0.0
# Предохранитель считается по каждому адресу отдельно: лежачая Ollama не должна
# гасить работающий deepseek-api и наоборот.
_down_until = {}
_up_until = {}
def _host_port():
parsed = urllib.parse.urlparse(config.OLLAMA_HOST)
def _host_port(host):
parsed = urllib.parse.urlparse(host)
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()
if now < _up_until:
if now < _up_until.get(host, 0.0):
return True
if now < _down_until:
if now < _down_until.get(host, 0.0):
return False
try:
host, port = _host_port()
socket.create_connection((host, port), timeout=CONNECT_TIMEOUT).close()
_up_until = now + UP_CACHE
_down_until = 0.0
socket.create_connection(_host_port(host), timeout=CONNECT_TIMEOUT).close()
_mark_up(host)
return True
except Exception:
_down_until = now + DOWN_COOLDOWN
_up_until = 0.0
_mark_down(host)
return False
def _mark_down():
def _mark_down(host):
"""Запрос не удался — держим паузу, чтобы не ждать таймаут на каждом шаге."""
global _down_until, _up_until
_down_until = time.time() + DOWN_COOLDOWN
_up_until = 0.0
_down_until[host] = time.time() + DOWN_COOLDOWN
_up_until.pop(host, None)
def _mark_up():
global _up_until
_up_until = time.time() + UP_CACHE
def _mark_up(host):
_up_until[host] = time.time() + UP_CACHE
_down_until.pop(host, None)
def reset_breaker():
"""Сбросить паузу — например, когда адрес поменяли в настройках."""
global _down_until, _up_until
_down_until = 0.0
_up_until = 0.0
def reset_breaker(host=None):
"""Сбросить паузу — например, когда адрес поменяли в настройках.
Без аргумента забываем всё: движок могли переключить, и прошлые неудачи
ничего не говорят о новом адресе.
"""
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."""
if not available():
host = host or config.chat_host()
if not available(host):
return False
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:
if r.status == 200:
_mark_up()
_mark_up(host)
return True
except Exception:
_mark_down()
# Пока браузер с чатом не поднят, deepseek-api отвечает 503 — это тоже
# «недоступен», и сюда оно приходит исключением HTTPError.
_mark_down(host)
return False
def models():
"""Список установленных моделей (для диагностики)."""
if not available():
def models(host=None):
"""Список моделей на этом адресе (для диагностики и выпадающих списков)."""
host = host or config.chat_host()
if not available(host):
return []
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:
data = json.loads(r.read().decode("utf-8"))
_mark_up()
_mark_up(host)
return [m.get("model", "") for m in data.get("models", [])]
except Exception as e:
_mark_down()
_mark_down(host)
logger.warning("Список моделей не получен: %s", e)
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):
"""Один запрос к /api/chat. Возвращает content или None при любой беде.
@@ -131,6 +145,8 @@ def chat(model, messages, timeout=CHAT_TIMEOUT, num_predict=MAX_OUTPUT_TOKENS,
внутренний монолог и возвращают пустой content проверено на qwen3:8b.
Модели без такого режима поле просто игнорируют.
"""
host = config.chat_host()
timeout = timeout or config.chat_timeout()
payload = {
"model": model,
"messages": messages,
@@ -138,19 +154,19 @@ def chat(model, messages, timeout=CHAT_TIMEOUT, num_predict=MAX_OUTPUT_TOKENS,
"think": think,
"options": {"num_predict": num_predict, "temperature": 0},
}
if not available():
if not available(host):
return None # сервер лежит: не ждём таймаут на каждой записи
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
config.OLLAMA_HOST + "/api/chat", data=data,
host + "/api/chat", data=data,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
resp = json.loads(r.read().decode("utf-8"))
_mark_up()
_mark_up(host)
except Exception as e:
_mark_down()
_mark_down(host)
logger.warning("Запрос к модели %s не удался: %s", model, e)
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
if not model or not image_bytes or not available():
if not model or not image_bytes or not available(host):
return None
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},
}
req = urllib.request.Request(
config.OLLAMA_HOST + "/api/chat",
host + "/api/chat",
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
resp = json.loads(r.read().decode("utf-8"))
_mark_up()
_mark_up(host)
return ((resp.get("message") or {}).get("content") or "").strip()
except Exception as e:
_mark_down()
_mark_down(host)
logger.warning("Не удалось распознать фото моделью %s: %s", model, e)
return None
@@ -234,20 +251,23 @@ def embed(text, is_query=False):
text = (text or "").strip()
if not text:
return None
if not available():
# Эмбеддинги всегда у настоящей Ollama, даже когда думает DeepSeek:
# веб-чат векторов не отдаёт.
host = config.OLLAMA_HOST
if not available(host):
return None
prefix = EMBED_QUERY_PREFIX if is_query else EMBED_DOC_PREFIX
data = json.dumps({"model": config.EMBED_MODEL, "prompt": prefix + text}).encode("utf-8")
req = urllib.request.Request(
config.OLLAMA_HOST + "/api/embeddings", data=data,
host + "/api/embeddings", data=data,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=EMBED_TIMEOUT) as r:
vector = json.loads(r.read().decode("utf-8")).get("embedding")
_mark_up()
_mark_up(host)
return vector
except Exception as e:
_mark_down()
_mark_down(host)
logger.warning("Эмбеддинг не получен: %s", e)
return None
+103 -31
View File
@@ -239,27 +239,50 @@
<!-- ================= Настройки ================= -->
<section class="tab" id="tab-setup">
<p class="hint" id="setupIntro">Здесь настраивается связь с Ollama — это «мозги»
помощника. Без неё всё остальное работает, но не будет разбора фраз,
автоматических тегов и поиска по смыслу.</p>
<p class="hint" id="setupIntro">Здесь настраиваются «мозги» помощника. Без них
всё остальное работает, но не будет разбора свободных фраз, автоматических
тегов и подсказок по фотографии.</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>
<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>
<p class="hint" id="cfgHostHint">Если 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>
<label id="cfgChatLabel">Модель для разбора фраз</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>
<p class="hint">Список заполняется моделями, которые реально есть.
Нужных нет — на машине с Ollama: <code>ollama pull qwen3:8b</code>
и <code>ollama pull bge-m3</code>.</p>
<div class="mt"><button class="act" id="cfgSave">Сохранить</button></div>
<p class="hint" id="cfgWhere"></p>
@@ -346,10 +369,12 @@ api('/api/health').then(h => {
if (h.ollama) return;
const b = $('#banner');
b.style.display = 'block';
const who = h.engine === 'deepseek' ? 'сервис DeepSeek' : 'Ollama';
b.textContent = h.fresh
? 'Похоже, помощник только установлен, а Ollama по адресу ' + h.host + ' не отвечает. '
: 'ИИ недоступен (' + h.host + '). Кнопки работают полностью; не будет только '
+ 'тегов и разбора свободных фраз. ';
? 'Похоже, помощник только установлен, а ' + who + ' по адресу ' + h.host
+ ' не отвечает. '
: 'ИИ недоступен: ' + who + ' по адресу ' + h.host + ' молчит. Кнопки работают '
+ 'полностью; не будет только тегов и разбора свободных фраз. ';
const link = document.createElement('a');
link.href = '#';
link.textContent = 'Настроить →';
@@ -362,13 +387,21 @@ api('/api/health').then(h => {
// ---------- настройки ----------
async function loadConfig() {
const d = await api('/api/config');
$('#cfgEngine').value = d.engine || 'ollama';
$('#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
+ ' — он не попадает в репозиторий.';
if (!d.available) {
$('#cfgProbeResult').innerHTML =
'<div class="card"><div class="addr taken">Ollama по этому адресу не отвечает.</div></div>';
// Ollama нужна всегда: даже когда думает DeepSeek, векторы для поиска
// по смыслу считает она — веб-чат их не отдаёт.
$('#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 || {});
}
@@ -460,30 +493,67 @@ $('#archiveInput').onchange = async () => {
// Модель выбирается из установленных; текущее значение показываем даже если
// его нет в списке — иначе выбор молча сбросился бы на чужую модель.
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)
// Списков два: разбор фраз может считать DeepSeek, а векторы для поиска
// по смыслу — всегда настоящая Ollama.
function fillModels(chatModels, embedModels, chat, embed) {
const opts = (list, extra) => ['<option value="">— не задана —</option>']
.concat(list.map(m => '<option value="' + esc(m) + '">' + esc(m) + '</option>'))
.concat(extra && !list.includes(extra)
? ['<option value="' + esc(extra) + '">' + esc(extra) + ' (не установлена)</option>'] : [])
.join('');
$('#cfgChat').innerHTML = opts(chat);
$('#cfgEmbed').innerHTML = opts(embed);
$('#cfgChat').innerHTML = opts(chatModels, chat);
$('#cfgEmbed').innerHTML = opts(embedModels, embed);
$('#cfgChat').value = chat || '';
$('#cfgEmbed').value = embed || '';
}
$('#cfgProbe').onclick = async () => {
const btn = $('#cfgProbe');
btn.disabled = true; btn.textContent = 'Проверяю…';
// Поле адреса DeepSeek показываем только когда он выбран, а подпись у Ollama
// меняем: в режиме DeepSeek она нужна уже не для мыслей, а только для поиска.
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 {
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'
body: JSON.stringify({host: $(input).value.trim()})});
$(box).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 = 'Проверить связь'; }
return d;
} 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 () => {
@@ -492,7 +562,9 @@ $('#cfgSave').onclick = async () => {
try {
const d = await api('/api/config', {method:'POST',
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,
embed_model: $('#cfgEmbed').value,
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("смена адреса сбрасывает паузу",
(ollama.reset_breaker() or True) and ollama._down_until == 0)
(ollama.reset_breaker() or True) and ollama._down_until == {})
finally:
config.OLLAMA_HOST = _was_host
ollama.reset_breaker()
@@ -393,6 +393,58 @@ r.check("инъекция в имя модели — 400",
c.post("/api/config",
json={"ollama_host": "http://127.0.0.1:11434",
"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("при отказе НИЧЕГО не применилось",