diff --git a/.env.example b/.env.example index dff7487..a0b5766 100644 --- a/.env.example +++ b/.env.example @@ -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: diff --git a/README.md b/README.md index f3d7da1..6bd8efe 100644 --- a/README.md +++ b/README.md @@ -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 разбор фразы моделью, синонимы, фолбэк на регулярки diff --git a/hlamingo/app.py b/hlamingo/app.py index e9f6905..f735fd1 100644 --- a/hlamingo/app.py +++ b/hlamingo/app.py @@ -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: diff --git a/hlamingo/config.py b/hlamingo/config.py index 3e74d3b..f729de1 100644 --- a/hlamingo/config.py +++ b/hlamingo/config.py @@ -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 diff --git a/hlamingo/ollama.py b/hlamingo/ollama.py index 5219fb6..f3a67fd 100644 --- a/hlamingo/ollama.py +++ b/hlamingo/ollama.py @@ -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 diff --git a/templates/index.html b/templates/index.html index 0f01930..1def3fa 100644 --- a/templates/index.html +++ b/templates/index.html @@ -239,27 +239,50 @@
-

Здесь настраивается связь с Ollama — это «мозги» - помощника. Без неё всё остальное работает, но не будет разбора фраз, - автоматических тегов и поиска по смыслу.

+

Здесь настраиваются «мозги» помощника. Без них + всё остальное работает, но не будет разбора свободных фраз, автоматических + тегов и подсказок по фотографии.

+ + + +

Ollama работает без интернета и ни о чём никому не сообщает. + DeepSeek умнее, но отвечает медленнее, требует открытого браузера + с выполненным входом, и ваши фразы уходят на его серверы.

+ + -

Если Ollama на этом же компьютере — http://localhost:11434. - Если на другой машине — её адрес в локальной сети.

+

Если Ollama на этом же компьютере — + http://localhost:11434. Если на другой машине — её адрес + в локальной сети.

- + -

Список заполняется моделями, которые реально установлены. - Нужных нет — на машине с Ollama: ollama pull qwen2.5:7b - и ollama pull nomic-embed-text.

+

Список заполняется моделями, которые реально есть. + Нужных нет — на машине с Ollama: ollama pull qwen3:8b + и ollama pull bge-m3.

@@ -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 = - '
Ollama по этому адресу не отвечает.
'; + // Ollama нужна всегда: даже когда думает DeepSeek, векторы для поиска + // по смыслу считает она — веб-чат их не отдаёт. + $('#cfgProbeResult').innerHTML = d.embed_available ? '' + : '
Ollama по этому адресу не отвечает.
'; + if (d.engine === 'deepseek') { + $('#cfgProbeDeepseekResult').innerHTML = d.available ? '' + : '
Сервис DeepSeek не отвечает. ' + + 'Запущен ли deepseek-api и открыт ли браузер с чатом?
'; } showBackupState(d.backup || {}); } @@ -460,30 +493,67 @@ $('#archiveInput').onchange = async () => { // Модель выбирается из установленных; текущее значение показываем даже если // его нет в списке — иначе выбор молча сбросился бы на чужую модель. -function fillModels(models, chat, embed) { - const opts = extra => [''] - .concat((models.length ? models : []).map(m => - '')) - .concat(extra && !models.includes(extra) +// Списков два: разбор фраз может считать DeepSeek, а векторы для поиска +// по смыслу — всегда настоящая Ollama. +function fillModels(chatModels, embedModels, chat, embed) { + const opts = (list, extra) => [''] + .concat(list.map(m => '')) + .concat(extra && !list.includes(extra) ? [''] : []) .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 на этом же компьютере — http://localhost:11434. ' + + 'Если на другой машине — её адрес в локальной сети.'; + $('#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 = '
' + esc(d.reply || '') + '
'; - 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()})}); diff --git a/tests/test_all.py b/tests/test_all.py index f7768a1..7ea5b92 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -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("при отказе НИЧЕГО не применилось",