Выбор мозгов: 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:
co-authored by
Claude Opus 5
parent
1ae98b0b6f
commit
4d8175931b
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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("при отказе НИЧЕГО не применилось",
|
||||
|
||||
Reference in New Issue
Block a user