Выбор мозгов: 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 и поправьте под себя.
|
||||||
# Файл .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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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("при отказе НИЧЕГО не применилось",
|
||||||
|
|||||||
Reference in New Issue
Block a user