From 8c52cf101cd847bcb50eab65aeb5da96d67480aa Mon Sep 17 00:00:00 2001 From: Alex Cube Date: Wed, 26 Aug 2026 13:09:41 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=B0=D0=BC=D0=BE=D0=B4=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=82=D0=BE=D1=87=D0=BD=D0=BE=D1=81=D1=82=D1=8C:?= =?UTF-8?q?=20=D0=B4=D0=B2=D0=B8=D0=B6=D0=BE=D0=BA=20=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D1=83=D0=B7=D0=B5=D1=80=D0=B0=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B5?= =?UTF-8?q?=D1=85=D0=B0=D0=BB=20=D0=B2=D0=BD=D1=83=D1=82=D1=80=D1=8C,=20De?= =?UTF-8?q?epShim=20=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=BD=D1=83=D0=B6=D0=B5=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше сервис был обёрткой: каждый запрос спавнил ollama_orchestro/ask.mjs дочерним процессом, гонял вопрос и ответ через временные файлы и читал чужой config.json ради профиля браузера. Работать это могло только рядом с установленным DeepShim. Теперь всё своё. browser.mjs — перенесённый из ask.mjs движок: CDP-сессия, помощники страницы со всем, что удалось выяснить про разметку DeepSeek, отправка с подтверждением, ожидание ответа по состоянию кнопки, работа с чатами. Пульт, разбор патчей и git остались в DeepShim, сюда не поехали. Побочно ушли дочерний процесс на каждый запрос и временные файлы: вопрос и ответ теперь просто переменные. Файл на диске остался только для картинки — иначе не наполнить. Свои настройки вместо чужих: browserExe, profileDir и cdpPort в config.json, рядом config.local.json для путей конкретной машины (в репозиторий не идёт). Свой chats.json, тоже не в репозитории: это рабочее состояние, оно переписывается при каждой смене чата. Образец — chats.example.json. Добавлен GET /api/probe: что видно во вкладке прямо сейчас. ВАЖНО: профиль браузера должен быть отдельный. Перед каждым вопросом сайдбар подчищается от всего, чего нет в своём chats.json, поэтому две программы на одном профиле удаляют чаты друг друга. Проверено вживую, описано в README. Проверки: node test.mjs; сквозной прогон с нуля — свой чат заведён сам, ответ верный; пять правок в одном чате, на шестом запросе новый чат, старый удалён, сайдбар не растёт. Co-Authored-By: Claude Opus 5 --- .gitignore | 6 + README.md | 281 +++++------ browser.mjs | 1128 ++++++++++++++++++++++++++++++++++++++++++++ chats.example.json | 14 + config.json | 4 +- server.mjs | 170 ++----- 6 files changed, 1307 insertions(+), 296 deletions(-) create mode 100644 browser.mjs create mode 100644 chats.example.json diff --git a/.gitignore b/.gitignore index 962829c..de7dc7d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ node_modules/ *.log + +# Рабочее состояние, а не настройка: id живых чатов. Файл заводится сам +# и переписывается при каждой смене чата. Образец — chats.example.json. +chats.json + +# Свой на каждой машине: пути к браузеру и профилю. config.local.json diff --git a/README.md b/README.md index 701a22a..7422d0b 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,9 @@ Платить не нужно — используется ваш обычный аккаунт DeepSeek. Взамен всё медленно (секунды, а не миллисекунды) и работает только пока открыт браузер. +Программа самодостаточна: кроме Node и браузера ей ничего не требуется, +внешних пакетов нет. + --- ## Оглавление @@ -40,11 +43,10 @@ | Что | Зачем | Как проверить, что есть | |---|---|---| -| **Windows** | скрипты запуска написаны под неё | — | +| **Windows** | скрипт запуска написан под неё | — | | **Node.js 22 или новее** | на нём написана программа | `node --version` → должно быть `v22.…` и выше | | **Браузер на движке Chromium** | Яндекс.Браузер, Chrome, Edge — любой | — | | **Аккаунт DeepSeek** | бесплатный, регистрация на [chat.deepseek.com](https://chat.deepseek.com) | — | -| **Проект DeepShim** | в нём живёт код, который умеет управлять вкладкой | ставится на шаге 1 | Node.js, если его нет, ставится с [nodejs.org](https://nodejs.org) — берите версию LTS, кнопка слева. После установки **закройте и откройте заново** @@ -54,40 +56,27 @@ Node.js, если его нет, ставится с [nodejs.org](https://nodejs ## Установка по шагам -Всего шагов семь. Делайте по порядку, каждый заканчивается проверкой — +Всего шагов пять. Делайте по порядку, каждый заканчивается проверкой — не переходите к следующему, пока предыдущий не дал ожидаемого результата. -### Шаг 1. Скачать обе программы - -Нужны две: эта и DeepShim, в котором лежит код управления браузером. -Положите их **рядом, в одну папку**: - -``` -C:\#Projects\ - deepseek-api\ <- эта программа - ollama_orchestro\ <- DeepShim -``` +### Шаг 1. Скачать ```cmd cd C:\#Projects git clone https://git.08h.ru/alex/deepseek-api.git -git clone https://git.08h.ru/alex/deepshim.git ollama_orchestro ``` -Папка DeepShim должна называться именно `ollama_orchestro` — так она указана -в настройках по умолчанию. Хотите иначе — поправите путь на шаге 6. +Без git можно скачать ZIP с той же страницы и распаковать. Папка может лежать +где угодно. -Без git можно просто скачать ZIP с тех же страниц и распаковать. - -> **Проверка:** в `C:\#Projects\deepseek-api\` лежит `server.mjs`, -> в `C:\#Projects\ollama_orchestro\` — `ask.mjs`. +> **Проверка:** в папке есть `server.mjs`, `browser.mjs` и `start.cmd`. ### Шаг 2. Завести отдельный профиль браузера Браузером будет управлять программа, поэтому работать в нём вам не нужно — -и не стоит: он будет самопроизвольно открываться и переключать вкладки. -Заведите под него **отдельный профиль** — это просто пустая папка, -браузер сам её заполнит: +и не стоит: он будет сам открываться, переключать вкладки и удалять чаты. +Заведите под него **отдельный профиль** — это просто пустая папка, браузер +сам её заполнит: ```cmd mkdir C:\Browsers\deepseek @@ -95,20 +84,22 @@ mkdir C:\Browsers\deepseek Имя любое, запомните путь — понадобится на следующем шаге. -### Шаг 3. Настроить DeepShim +> **Профиль должен быть свой.** Если в том же профиле работает другая +> программа, управляющая чатами DeepSeek (например, +> [DeepShim](https://git.08h.ru/alex/deepshim)), они начнут удалять чаты друг +> друга: каждая считает лишним всё, чего нет в её собственном списке. -В папке `ollama_orchestro` создайте файл `config.json`: +### Шаг 3. Указать путь к браузеру и профилю + +Откройте `config.json` и поправьте два значения: ```json { - "profileDir": "C:\\Browsers\\deepseek", "browserExe": "C:\\Program Files\\Yandex\\YandexBrowser\\Application\\browser.exe", - "port": 9333 + "profileDir": "C:\\Browsers\\deepseek" } ``` -Три значения: - - **`profileDir`** — папка из шага 2. - **`browserExe`** — путь к браузеру. Типичные: @@ -118,82 +109,20 @@ mkdir C:\Browsers\deepseek | Google Chrome | `C:\Program Files\Google\Chrome\Application\chrome.exe` | | Microsoft Edge | `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe` | -- **`port`** — порт, по которому программа управляет браузером. `9333` подходит, - меняйте только если он у вас занят. - **Обратные слэши в JSON удваиваются** — `C:\\Browsers\\deepseek`, а не `C:\Browsers\deepseek`. С одинарными файл не прочитается. -> **Проверка:** откройте `config.json` в браузере (перетащите файл в окно). -> Если показалась структура — JSON верный. Если ошибка — ищите пропущенную -> запятую или одинарный слэш. +> Чтобы обновление программы не затирало ваши пути, их можно положить +> не в `config.json`, а в `config.local.json` рядом — он перебивает основной +> и в репозиторий не попадает. -### Шаг 4. Запустить браузер и войти в DeepSeek +> **Проверка:** перетащите `config.json` в окно браузера. Показалась +> структура — JSON верный. Ошибка — ищите пропущенную запятую или одинарный +> слэш. + +### Шаг 4. Запустить ```cmd -cd C:\#Projects\ollama_orchestro -start-browser.cmd -``` - -Откроется браузер с чистым профилем на странице DeepSeek. **Войдите -в аккаунт.** Логин сохранится в профиле — больше делать это не придётся. - -> **Проверка:** в окне командной строки написано -> `READY. … is listening on port 9333.` -> Если написано `Port 9333 did not answer` — см. [Если не работает](#если-не-работает). - -**Окно браузера не сворачивайте и не закрывайте.** Свёрнутое окно Windows -не отрисовывает, и программа не сможет прочитать ответ. Достаточно перевести -его на другой рабочий стол или просто оставить позади других окон. - -### Шаг 5. Создать три рабочих чата - -DeepSeek выбирает режим (обычный / с поиском в интернете / со зрением) -**в момент создания чата**, и потом переключить его нельзя — тумблеры -исчезают после первого сообщения. Поэтому под каждый режим нужен свой чат. - -Создадутся они сами: - -```cmd -cd C:\#Projects\ollama_orchestro -node ask.mjs init -``` - -Команда откроет три новых чата, задаст в каждом по вопросу и запишет их -номера в `chats.json`. Займёт минуту-две, окно браузера в это время будет -само щёлкать — так и надо, не мешайте. - -> **Проверка:** в конце напечатается что-то вроде -> ``` -> Сохранено в chats.json: -> think expert af016e20-… -> search default+… 8021bbc1-… -> vision vision 14663b36-… -> ``` - -### Шаг 6. Настроить deepseek-api - -Файл `config.json` в папке `deepseek-api` уже готов: - -```json -{ - "port": 11500, - "host": "0.0.0.0", - "bridge": "C:/#Projects/ollama_orchestro/ask.mjs", - "chatUrl": "https://chat.deepseek.com/", - "defaultRole": "think", - "timeout": 300 -} -``` - -Если вы разложили папки как в шаге 1 — **менять здесь нечего, переходите -к шагу 7.** Если DeepShim лежит в другом месте, поправьте `bridge`. Здесь -слэши обычные, прямые (`C:/…`), а не обратные. - -### Шаг 7. Запустить - -```cmd -cd C:\#Projects\deepseek-api start.cmd ``` @@ -206,26 +135,34 @@ start.cmd Это окно должно оставаться открытым — пока оно живо, работает и сервис. Останов — `Ctrl+C` или просто закрыть окно. -**Готово.** Дальше — проверка. +### Шаг 5. Войти в DeepSeek ---- - -## Проверка, что всё работает - -Откройте **вторую** командную строку (первая занята сервисом) и спросите -что-нибудь: +Браузер поднимается сам, но в пустом профиле вы ещё не залогинены. Дайте +сервису первый запрос — он откроет окно: ```cmd curl http://localhost:11500/api/tags ``` -Ожидается список из трёх «моделей»: +Первый вызов ответит `503` и поднимет браузер. **Войдите в аккаунт DeepSeek +в открывшемся окне.** Логин сохранится в профиле — больше делать это +не придётся. -```json -{"models":[{"name":"deepseek:think",…},{"name":"deepseek:search",…},{"name":"deepseek:vision",…}]} -``` +**Окно браузера не сворачивайте и не закрывайте.** Свёрнутое окно Windows +не отрисовывает, и программа не сможет прочитать ответ. Достаточно перевести +его на другой рабочий стол или оставить позади других окон. -Теперь настоящий вопрос: +> **Проверка:** повторите `curl http://localhost:11500/api/tags` — теперь +> должен прийти список из трёх «моделей». + +**Готово.** Чаты под каждый режим программа заведёт сама при первом же +вопросе — создавать их руками не нужно. + +--- + +## Проверка, что всё работает + +Откройте **вторую** командную строку (первая занята сервисом): ```cmd curl http://localhost:11500/api/chat -d "{\"model\":\"deepseek:think\",\"stream\":false,\"messages\":[{\"role\":\"user\",\"content\":\"Ответь одним словом: столица Японии?\"}]}" @@ -237,13 +174,25 @@ curl http://localhost:11500/api/chat -d "{\"model\":\"deepseek:think\",\"stream\ {"model":"deepseek:think","created_at":"…","message":{"role":"assistant","content":"Токио"},"done":true,"done_reason":"stop"} ``` -Если пришло — всё работает. В окне сервиса при этом видно, сколько заняло: +В окне сервиса при этом видно, что происходило: ``` [think] 37 симв. -[think] ответ 5 симв. за 8 с + чат think не найден, создаю сразу с вопросом + открыт новый чат + создан новый чат think: 660347d5-… +[think] ответ 5 симв. за 9 с ``` +Отдельная диагностика — что сейчас видно во вкладке: + +```cmd +curl http://localhost:11500/api/probe +``` + +Покажет адрес вкладки, найдено ли поле ввода, идёт ли генерация, список чатов +и не просит ли сайт залогиниться. + --- ## Как подключить свою программу @@ -257,8 +206,9 @@ http://localhost:11500 Моделью выберите `deepseek:think`. Если программа работает на **другом компьютере** в вашей сети — вместо -`localhost` укажите адрес этого компьютера, например `http://192.168.0.10:11500`. -Сервис слушает все адреса, ничего дополнительно настраивать не нужно. +`localhost` укажите адрес этого компьютера, например +`http://192.168.0.10:11500`. Сервис слушает все адреса, ничего дополнительно +настраивать не нужно. ### Три «модели» — это три режима @@ -268,9 +218,9 @@ http://localhost:11500 | `deepseek:search` | с поиском в интернете | нужны свежие данные | | `deepseek:vision` | со зрением | разбор картинок | -Картинку можно просто приложить к запросу, как это делает Ollama — -в поле `images` массивом base64. Тогда режим `vision` включится сам, -независимо от того, какую модель вы указали. +Картинку можно приложить к запросу так же, как это делает Ollama — в поле +`images` массивом base64. Тогда режим `vision` включится сам, независимо +от того, какую модель вы указали. **Незнакомое имя модели — не ошибка.** Если программа настойчиво шлёт свою `llama3` или `qwen3:8b`, запрос уйдёт в обычный чат. Это сделано нарочно: @@ -292,6 +242,7 @@ http://localhost:11500 |---|---| | `GET /api/tags` | список «моделей». **Отвечает 503, пока браузер не поднят** — по этому клиенты понимают, что сервис не готов | | `GET /api/version` | опознавательная строка | +| `GET /api/probe` | сверх протокола Ollama: что видно во вкладке прямо сейчас | | `POST /api/chat` | сообщения → ответ | | `POST /api/generate` | то же, но вопрос одной строкой | | `POST /api/embeddings` | **501** — веб-чат векторов не отдаёт | @@ -314,11 +265,12 @@ http://localhost:11500 ### Сколько ждать -Порядок величин, замерено на разборе фразы и генерации тегов: +Порядок величин, замерено на коротких вопросах: | | Ollama (qwen3:8b, локально) | DeepSeek (веб) | |---|---|---| -| короткий вопрос | 2–3 с | 8–12 с | +| короткий вопрос | 2–3 с | 7–12 с | +| запрос, на котором меняется чат | — | ~16 с | | первый запрос после запуска браузера | — | +15 с на подъём окна | Если ваша программа считает, что модель обязана ответить за пару секунд, — @@ -334,7 +286,9 @@ http://localhost:11500 |---|---|---| | `port` | `11500` | порт, на котором сервис слушает | | `host` | `0.0.0.0` | какие адреса слушать; `127.0.0.1` — только этот компьютер | -| `bridge` | `C:/#Projects/ollama_orchestro/ask.mjs` | путь к DeepShim | +| `cdpPort` | `9333` | порт, по которому программа управляет браузером | +| `browserExe` | Яндекс.Браузер | путь к браузеру | +| `profileDir` | `C:\Browsers\deepseek` | папка профиля браузера | | `chatUrl` | `https://chat.deepseek.com/` | что открывать во вкладке | | `defaultRole` | `think` | режим для незнакомых имён моделей | | `timeout` | `300` | сколько секунд ждать **бездействия** страницы | @@ -342,9 +296,12 @@ http://localhost:11500 Про `timeout`: это не «сколько ждать ответа». Пока DeepSeek печатает, ждём сколько угодно; срок отсчитывается от последнего признака жизни страницы. +`config.local.json` рядом перебивает `config.json` и в репозиторий не +попадает — туда удобно класть пути конкретной машины. + ### Переменные окружения -Перебивают файл, удобны для разовых запусков: +Перебивают файлы, удобны для разовых запусков: ```cmd set DEEPSEEK_API_PORT=11501 @@ -352,9 +309,10 @@ set DEEPSEEK_CDP_PORT=9444 start.cmd ``` -Профиль браузера, путь к нему и порт управления берутся из -`ollama_orchestro/config.json` — там уже выполнен вход в DeepSeek, и второй -экземпляр этих настроек однажды разошёлся бы с первым. +### `chats.json` + +Заводится сам, руками его трогать не нужно — см. +[Чаты изнашиваются](#чаты-изнашиваются-и-меняются-сами). --- @@ -365,29 +323,21 @@ start.cmd | Что видно | В чём дело | Что делать | |---|---|---| | `Браузер с отладочным портом 9333 не отвечает` в ответе на `/api/tags` | браузер закрыт | Ничего: он поднимется сам при первом запросе к `/api/chat`. Если не поднялся — следующая строка | -| `Браузер так и не открыл отладочный порт` | этот профиль уже открыт **без** порта управления: новый процесс отдаёт ссылку старому окну и молча завершается | Закройте **все** окна браузера с этим профилем и повторите. Проверить, что не осталось: диспетчер задач, процессы браузера | -| `в config.json не задан profileDir или browserExe` | шаг 3 пропущен или файл лежит не в той папке | `ollama_orchestro\config.json`, оба ключа заполнены | -| `мост вышел с кодом 1. Вкладка chat.deepseek.com не открыта` | вкладку закрыли руками | Откройте `chat.deepseek.com` в том же окне браузера, или перезапустите его через `start-browser.cmd` | +| `Браузер так и не открыл отладочный порт` | этот профиль уже открыт **без** порта управления: новый процесс отдаёт ссылку старому окну и молча завершается | Закройте **все** окна браузера с этим профилем и повторите. Проверить, что не осталось: диспетчер задач | +| `Браузер не найден: …` | неверный `browserExe` | Проверьте путь и удвоенные слэши | +| `не задан browserExe или profileDir` | шаг 3 пропущен | Заполните оба ключа | +| `Вкладка chat.deepseek.com не открыта` | вкладку закрыли руками | Сервис откроет её сам на следующем запросе; если нет — откройте `chat.deepseek.com` в том же окне | | Ответ приходит **обрезанным**, без кода | окно браузера свёрнуто — Windows перестаёт его отрисовывать, а DeepSeek дорисовывает блоки кода лениво | Разверните окно. Не сворачивайте: уводите на другой рабочий стол или за другие окна | | Ответ приходит **чужой**, не на ваш вопрос | вкладка не успела обновиться | Повторите запрос. Если повторяется — перезапустите браузер | -| В окне мелькнуло `правки в этом чате кончились, завожу новый` | исчерпан лимит в шесть правок одного сообщения | Ничего. Это штатная смена чата, см. [Чаты изнашиваются](#чаты-изнашиваются-и-меняются-сами) | +| В логе `правки в этом чате кончились, завожу новый` | исчерпан лимит в шесть правок одного сообщения | Ничего. Это штатная смена чата, см. [Чаты изнашиваются](#чаты-изнашиваются-и-меняются-сами) | +| Чаты пропадают сами, хотя вы их не удаляли | тот же профиль браузера использует другая программа, управляющая чатами | Дайте каждой свой `profileDir` | | `EADDRINUSE: address already in use` | сервис уже запущен в другом окне | Закройте то окно либо возьмите другой порт (`DEEPSEEK_API_PORT`) | | `node не является внутренней или внешней командой` | Node не установлен или командная строка открыта до его установки | Поставьте Node, **закройте и откройте заново** командную строку | -| Просит войти в DeepSeek при каждом запуске | профиль браузера каждый раз новый | Проверьте, что `profileDir` указывает на одну и ту же существующую папку | - -Отдельная диагностика самого моста — из папки DeepShim: - -```cmd -node ask.mjs probe -``` - -Она скажет, видна ли вкладка, найдено ли поле ввода и не просит ли сайт -залогиниться. +| Просит войти в DeepSeek при каждом запуске | профиль каждый раз новый | Проверьте, что `profileDir` указывает на одну и ту же существующую папку | Проверить перевод формата, не поднимая браузер: ```cmd -cd C:\#Projects\deepseek-api node test.mjs ``` @@ -426,12 +376,10 @@ POST /api/chat ├─ выбрать чат по имени модели (think / search / vision) ├─ встать в очередь (вкладка одна) │ - ├─> написать вопрос во временный файл - ├─> node ask.mjs ask --file … --out … (дочерний процесс) - │ └─> Chrome DevTools Protocol - │ └─> правка ПЕРВОГО сообщения чата - │ └─> дождаться, пока DeepSeek допечатает - ├─< прочитать ответ из файла + ├─> поднять браузер, если он не отвечает + ├─> Chrome DevTools Protocol + │ └─> правка ПЕРВОГО сообщения чата + │ └─> дождаться, пока DeepSeek допечатает │ └─ отдать в формате Ollama ``` @@ -441,46 +389,51 @@ POST /api/chat Контекст не растёт, мусор между запросами не копится, каждый вопрос — с чистого листа. -Почему дочерний процесс, а не импорт: `ask.mjs` — программа командной строки, -она ничего не экспортирует и выполняется сразу при подключении. Переделывать -чужой проект ради этого не стали. - -Почему браузер поднимается напрямую, а не через `browser-launch.ps1` -из DeepShim: тот скрипт рассчитан на человека за консолью — на занятом профиле -он задаёт вопрос и ждёт ответа, а у сервиса некому отвечать. +Почему браузер запускается напрямую из программы, а не батником: батник +рассчитан на человека за консолью — на занятом профиле он задаёт вопрос +и ждёт ответа, а у сервиса некому отвечать. ### Чаты изнашиваются и меняются сами **Одно сообщение DeepSeek разрешает править ровно шесть раз.** После шестой правки кнопка перестаёт открывать поле ввода — и чат становится непригоден. -Ничего делать не нужно, это уже предусмотрено. На седьмом вопросе DeepShim +Ничего делать не нужно, это предусмотрено. На седьмом вопросе программа заводит новый чат того же режима, задаёт вопрос сразу в нём, записывает новый номер в `chats.json` и **удаляет отработавший чат**. Снаружи не видно ничего: тот же запрос, тот же ответ, разве что на несколько секунд дольше. -Отсюда два следствия: +Отсюда следствия: - **`chats.json` — не настройка, а переменная.** Номера в нём меняются сами - по мере работы. Не правьте его руками и не удивляйтесь, если git покажет - в нём изменения. + по мере работы. Не правьте его руками; в репозиторий он не попадает, + образец структуры — `chats.example.json`. - **В сайдбаре DeepSeek не копится мусор.** Старые чаты удаляются, плюс перед - каждым вопросом сайдбар подчищается от посторонних служебных чатов. + каждым вопросом сайдбар подчищается от всего, чего нет в `chats.json`. + Именно поэтому профиль браузера должен быть свой: чужие чаты в нём + программа считает мусором. +- Если вы удалите рабочий чат вручную — ничего страшного: следующий запрос + заметит пропажу и создаст новый. -Если вы удалите рабочий чат вручную — тоже ничего страшного: следующий запрос -заметит пропажу и создаст новый. - -Файлы: +### Файлы | Файл | Что в нём | |---|---| -| `server.mjs` | всё: HTTP, очередь, запуск браузера, вызов моста | -| `config.json` | настройки этой машины | +| `server.mjs` | HTTP, перевод формата Ollama, очередь | +| `browser.mjs` | всё про браузер: CDP, разметка DeepSeek, отправка, ожидание ответа, чаты | +| `config.json` | настройки; `config.local.json` рядом перебивает их | +| `chats.json` | рабочее состояние: id живых чатов (заводится сам) | | `start.cmd` | запуск | | `test.mjs` | проверка перевода формата, браузер не нужен | Зависимостей нет — только то, что есть в Node 22. +Движок управления вкладкой перенесён из +[DeepShim](https://git.08h.ru/alex/deepshim) и дальше живёт здесь своей жизнью. +Там он часть большого инструмента с веб-пультом и разбором патчей; здесь нужен +только чат. Всё, что удалось выяснить про разметку DeepSeek, перенесено вместе +с комментариями — это знание дороже кода. + --- ## Лицензия diff --git a/browser.mjs b/browser.mjs new file mode 100644 index 0000000..b018c47 --- /dev/null +++ b/browser.mjs @@ -0,0 +1,1128 @@ +// Управление вкладкой DeepSeek через Chrome DevTools Protocol. +// +// Перенесено из ask.mjs проекта DeepShim (git.08h.ru/alex/deepshim), чтобы +// сервис был самодостаточным: там это часть большого инструмента с веб-пультом +// и разбором патчей, здесь нужен только чат. Всё, что удалось выяснить про +// разметку DeepSeek, сохранено вместе с комментариями — это знание дороже кода. +// +// Зависимостей нет: Node 22+ со встроенными fetch и WebSocket. + +import { readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, resolve as resolvePath } from 'node:path'; +import { existsSync } from 'node:fs'; +import { spawn } from 'node:child_process'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CHATS_FILE = join(HERE, 'chats.json'); +const CHAT_HOST = 'chat.deepseek.com'; + +// Настройки браузера приходят снаружи (из config.json сервиса) — модуль +// не лезет в чужие файлы и не знает, где живёт вызывающий. +let PORT = 9333; +let BROWSER = { exe: '', profileDir: '', url: `https://${CHAT_HOST}/` }; + +export function configure({ cdpPort, browserExe, profileDir, chatUrl }) { + if (cdpPort) PORT = Number(cdpPort); + if (browserExe !== undefined) BROWSER.exe = browserExe; + if (profileDir !== undefined) BROWSER.profileDir = profileDir; + if (chatUrl) BROWSER.url = chatUrl; +} + +// Роли — это отдельные чаты под разные режимы. Режим выбирается при СОЗДАНИИ +// чата и потом не меняется: тумблеры исчезают после первого сообщения. +export const ROLES = ['think', 'search', 'vision']; + +// Роль → id чата из chats.json. Файл — рабочее состояние, а не настройка: +// он заводится сам и переписывается при каждой смене чата. +async function chatsMap() { + try { return JSON.parse(await readFile(CHATS_FILE, 'utf8')); } catch { return {}; } +} + + + +// ------------------------------------------------------------------ CDP + +async function findChatTab() { + let targets; + try { + const res = await fetch(`http://127.0.0.1:${PORT}/json`, { signal: AbortSignal.timeout(3000) }); + targets = await res.json(); + } catch { + throw new Error( + `Браузер с отладочным портом ${PORT} не найден. Обычно его поднимает сам ` + + 'сервис; если этого не произошло, проверьте browserExe и profileDir ' + + 'в config.json.'); + } + const pages = targets.filter(t => t.type === 'page'); + const chat = pages.find(t => String(t.url).includes(CHAT_HOST)); + if (!chat) { + throw new Error( + `Вкладка ${CHAT_HOST} не открыта. Открыто:\n` + + pages.map(p => ' ' + p.url).join('\n')); + } + return chat; +} + +class Session { + #ws; #id = 1; #pending = new Map(); + + static connect(wsUrl) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl); + const s = new Session(); + s.#ws = ws; + ws.addEventListener('open', () => resolve(s), { once: true }); + ws.addEventListener('error', e => reject(new Error(`WebSocket: ${e?.message ?? e}`)), { once: true }); + ws.addEventListener('message', ev => { + let m; try { m = JSON.parse(ev.data); } catch { return; } + if (m.id === undefined) return; + const p = s.#pending.get(m.id); + if (!p) return; + s.#pending.delete(m.id); + if (m.error) p.reject(new Error(m.error.message)); else p.resolve(m.result); + }); + }); + } + + send(method, params = {}) { + const id = this.#id++; + return new Promise((resolve, reject) => { + this.#pending.set(id, { resolve, reject }); + this.#ws.send(JSON.stringify({ id, method, params })); + }); + } + + async eval(expression, awaitPromise = true) { + const r = await this.send('Runtime.evaluate', { + expression, returnByValue: true, awaitPromise, userGesture: true, + }); + if (r.exceptionDetails) { + throw new Error(`JS на странице: ${r.exceptionDetails.exception?.description ?? r.exceptionDetails.text}`); + } + return r.result?.value; + } + + close() { try { this.#ws.close(); } catch { } } +} + +async function withChat(fn) { + const tab = await findChatTab(); + const s = await Session.connect(tab.webSocketDebuggerUrl); + try { return await fn(s, tab); } finally { s.close(); } +} + +// -------------------------------------------------- код, живущий на странице + + +// Поле ввода: у DeepSeek это textarea, но разметка меняется — ищем устойчиво. +const PAGE_HELPERS = ` +function __visible(el) { + const r = el.getBoundingClientRect(); + const cs = getComputedStyle(el); + return r.width > 0 && r.height > 0 && cs.visibility !== 'hidden' && cs.display !== 'none'; +} +function __input() { + const cands = [ + ...document.querySelectorAll('textarea'), + ...document.querySelectorAll('[contenteditable="true"]') + ].filter(__visible); + if (cands.length === 0) return null; + // самое нижнее видимое поле — это строка ввода чата + return cands.sort((a, b) => b.getBoundingClientRect().top - a.getBoundingClientRect().top)[0]; +} +function __answerNodes() { + const selectors = ['.ds-markdown', '[class*="markdown"]', '[data-message-id]', '[class*="message-content"]']; + for (const sel of selectors) { + const found = [...document.querySelectorAll(sel)].filter(__visible); + if (found.length) return { sel, nodes: found }; + } + return { sel: null, nodes: [] }; +} +function __toggles() { + // В диалоге подписи пропадают и остаются иконки, поэтому опора на класс тумблера, + // а текст/подсказка — лишь для имени. + return [...document.querySelectorAll('div[role="button"], button, [class*="toggle-button"]')] + .filter(__visible) + .filter(e => { + const c = e.getAttribute('class') || ''; + if (/toggle-button__/.test(c)) return false; // вложенные иконки, не сами тумблеры + return /ds-toggle-button/.test(c) || /^(DeepThink|Search)$/i.test((e.innerText || '').trim()); + }) + .map(e => { + const cls = e.getAttribute('class') || ''; + return { + name: (e.innerText || '').trim() || + e.getAttribute('aria-label') || e.getAttribute('title') || + (e.querySelector('[aria-label]')?.getAttribute('aria-label')) || '(без подписи)', + // активный тумблер помечается доп. классом либо aria-атрибутом + on: /active|checked|selected|--on\\b/i.test(cls) || + e.getAttribute('aria-pressed') === 'true' || + e.getAttribute('aria-checked') === 'true', + cls + }; + }); +} +function __clickToggle(name) { + // Ищем среди тех же элементов, что показывает __toggles: тумблер — это div + // с классом ds-toggle-button, а не button с role. + const el = [...document.querySelectorAll('div[role="button"], button, [class*="toggle-button"]')] + .filter(__visible) + .filter(e => !/toggle-button__/.test(e.getAttribute('class') || '')) + .find(e => (e.innerText || '').trim().toLowerCase() === name.toLowerCase()); + if (!el) return { ok: false, reason: 'кнопка ' + name + ' не найдена' }; + el.click(); + return { ok: true }; +} +// Режим (Search / DeepThink) задаётся при СОЗДАНИИ чата и потом не меняется: +// тумблеры пропадают, как только в чате появилось первое сообщение. Поэтому +// работа идёт через переключение между заранее созданными чатами в сайдбаре. +function __chatItems() { + const byHref = [...document.querySelectorAll('a[href*="/chat/s/"]')].filter(__visible); + if (byHref.length) { + return { how: 'ссылки', items: byHref.map(e => ({ + title: (e.innerText || '').replace(/\\s+/g, ' ').trim().slice(0, 80), + href: e.getAttribute('href'), + current: location.href.includes((e.getAttribute('href') || '').split('/').pop()) + })) }; + } + // Сайдбар без : берём кликабельные строки с текстом из левой колонки. + const rows = [...document.querySelectorAll('div[class*="chat"], div[class*="history"], li')] + .filter(__visible) + .filter(e => e.getBoundingClientRect().left < 320 && e.getBoundingClientRect().width < 340) + .filter(e => { const t = (e.innerText || '').trim(); return t && t.length < 120; }); + const uniq = rows.filter(e => !rows.some(o => o !== e && o.contains(e))); + return { how: 'сайдбар', items: uniq.map(e => ({ + title: (e.innerText || '').replace(/\\s+/g, ' ').trim().slice(0, 80), href: null, current: false + })) }; +} +function __openChat(needle) { + const low = String(needle).toLowerCase(); + const cand = [...document.querySelectorAll('a[href*="/chat/s/"]')].filter(__visible) + .find(e => (e.innerText || '').toLowerCase().includes(low) || + (e.getAttribute('href') || '').toLowerCase().includes(low)); + if (cand) { cand.click(); return { ok: true, title: (cand.innerText || '').trim() }; } + const rows = __chatItems().items; + const idx = rows.findIndex(r => r.title.toLowerCase().includes(low)); + if (idx === -1) return { ok: false, reason: 'чат не найден: ' + needle }; + const all = [...document.querySelectorAll('div[class*="chat"], div[class*="history"], li')] + .filter(__visible).filter(e => (e.innerText || '').replace(/\\s+/g, ' ').trim().slice(0, 80) === rows[idx].title); + if (!all.length) return { ok: false, reason: 'не удалось кликнуть по чату' }; + all[0].click(); + return { ok: true, title: rows[idx].title }; +} +// Индикатор выбранной модели рядом с полем ввода: Instant / DeepThink / Search. +// Это единственный надёжный признак — классы тумблеров врут, а подпись показывает, +// чем чат будет отвечать на самом деле. +function __modelLabel() { + // Выбор модели — это radiogroup: div[data-model-type=default|expert|vision] + // с aria-checked. Это точный источник, подписи и классы вторичны. + const radios = [...document.querySelectorAll('[data-model-type][role="radio"]')]; + if (radios.length) { + const on = radios.find(r => r.getAttribute('aria-checked') === 'true'); + return on ? [on.getAttribute('data-model-type')] : []; + } + // Чат уже начат: radiogroup спрятан, остаётся только подпись рядом с полем ввода. + const known = /^(instant|expert|vision)$/i; + return [...document.querySelectorAll('span, div')] + .filter(__visible) + .filter(e => !e.children.length && known.test((e.innerText || '').trim())) + .filter(e => !e.closest('[class*="ds-toggle-button"]')) + .map(e => (e.innerText || '').trim().toLowerCase()); +} +// Только координаты: сам клик — настоящими мышиными событиями снаружи. +// Причина найдена замером 26.08.2026: btn.click() из DOM React глотает молча, +// адрес не менялся, а __newChat отчитывался успехом. Дальше makeChat считывал +// СТАРЫЙ id, считал его новым, ничего не удалял, и вопрос уходил в тот же чат. +// Наружу это выглядело как «ротация не работает». +function __newChatBox() { + const btn = [...document.querySelectorAll('div, button, a')].filter(__visible) + .find(e => /^new chat$/i.test((e.innerText || '').trim())); + if (!btn) return null; + const r = btn.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; +} +// Список сообщений виртуализован; единственный data-атрибут на странице — +// data-virtual-list-item-key. Ответ отличается от вопроса тем, что внутри +// него есть .ds-markdown. +// +// За номер ключа цепляться НЕЛЬЗЯ: после каждой правки список +// перенумеровывается (было 1/2, стало 3/4), а невидимых элементов в DOM +// вообще нет — виртуализация их выбрасывает. Берём самый ранний из +// отрисованных вопросов. После правки в чате остаётся ровно один обмен, +// поэтому такой вопрос там один. +function __firstMsg() { + const items = [...document.querySelectorAll('[data-virtual-list-item-key]')] + .map(e => ({ e, k: Number(e.getAttribute('data-virtual-list-item-key')) })) + .filter(o => Number.isFinite(o.k) && !o.e.querySelector('.ds-markdown')) + .sort((a, b) => a.k - b.k); + return items.length ? items[0].e : null; +} +// Кнопки под сообщением (копировать, редактировать). Появляются по наведению +// и прячутся, когда курсор уходит вниз к ним, поэтому жать только из DOM. +function __msgButtons() { + const it = __firstMsg(); + if (!it) return []; + return [...it.querySelectorAll('[role="button"], button')].filter(__visible); +} +function __clickEdit() { + const b = __msgButtons(); + if (b.length < 2) return { ok: false, reason: 'кнопок под сообщением ' + b.length }; + b[1].click(); // вторая — редактирование, первая — копирование + return { ok: true }; +} +// Поле правки — любая видимая textarea ВЫШЕ поля ввода (оно всегда самое нижнее). +function __editArea() { + const all = [...document.querySelectorAll('textarea')].filter(__visible) + .sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top); + return all.length > 1 ? all[0] : null; +} +function __setEditText(value) { + const el = __editArea(); + if (!el) return { ok: false, reason: 'поле правки не открылось' }; + el.focus(); + const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; + setter.call(el, value); + el.dispatchEvent(new Event('input', { bubbles: true })); + return { ok: true, len: el.value.length }; +} +// Все чаты сайдбара. Адресоваться только по id: порядок меняется — тот, +// куда писали последним, встаёт первым, поэтому индексы протухают между шагами. +function __chatIds() { + return [...document.querySelectorAll('a[href^="/a/chat/s/"]')] + .filter(__visible) + .map(e => ({ id: e.getAttribute('href').split('/').pop(), title: (e.innerText || '').trim().slice(0, 40) })); +} +// Куда навести мышь, чтобы у чата проявились три точки. +function __chatRowBox(id) { + const a = [...document.querySelectorAll('a[href^="/a/chat/s/"]')] + .find(e => e.getAttribute('href').endsWith(id)); + if (!a) return null; + a.scrollIntoView({ block: 'center' }); + const r = a.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), top: r.y, bottom: r.y + r.height }; +} +// Кнопка "..." ИМЕННО этой строки: её центр по вертикали лежит внутри строки. +// Брать самую правую видимую кнопку нельзя — у активного чата точки видны +// всегда, и однажды так удалился рабочий чат вместо мусорного. +function __dotsBox(top, bottom) { + const d = [...document.querySelectorAll('[role="button"], button')] + .filter(__visible) + .map(e => ({ r: e.getBoundingClientRect() })) + .filter(o => o.r.left < 340 && o.r.width < 60 && o.r.height < 60) + .filter(o => { const cy = o.r.y + o.r.height / 2; return cy >= top && cy <= bottom; }) + .sort((p, q) => q.r.x - p.r.x)[0]; + return d ? { x: Math.round(d.r.x + d.r.width / 2), y: Math.round(d.r.y + d.r.height / 2) } : null; +} +function __menuItemBox(re) { + for (const e of document.querySelectorAll('div,span,li,button')) { + if (e.children.length || !__visible(e)) continue; + if (!re.test((e.innerText || '').trim())) continue; + const r = e.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + } + return null; +} +// Кнопка подтверждения в диалоге. Подпись лежит во вложенном элементе, +// поэтому фильтр «элемент без детей» её не находит — берём самый мелкий. +function __confirmBox() { + const box = [...document.querySelectorAll('[class*="modal"],[class*="dialog"],[role="dialog"],[class*="confirm"]')] + .filter(__visible).pop(); + if (!box) return null; + return [...box.querySelectorAll('*')] + .filter(e => __visible(e) && /^(delete chat|delete|удалить)$/i.test((e.innerText || '').trim())) + .map(e => { const r = e.getBoundingClientRect(); + return { area: r.width * r.height, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; }) + .sort((a, b) => a.area - b.area)[0] || null; +} +function __chatId() { + const m = location.href.match(/\\/chat\\/s\\/([0-9a-f-]+)/i); + return m ? m[1] : null; +} +// Кнопка отправки. Подписи и aria у неё нет, но класс-модификатор устойчив: +// ds-button--primary есть только у неё (проверено 06.08.2026 — на странице +// ровно один такой элемент). Геометрия оставлена запасным путём на случай, +// если вёрстка поедет. +function __sendButton() { + const byClass = [...document.querySelectorAll('[role="button"], button')] + .filter(__visible) + .filter(e => /\\bds-button--primary\\b/.test(e.getAttribute('class') || '')); + if (byClass.length) return byClass[byClass.length - 1]; + + const ta = __input(); + if (!ta) return null; + const tr = ta.getBoundingClientRect(); + return [...document.querySelectorAll('div[role="button"], button')] + .filter(__visible) + .filter(e => /ds-button/.test(e.getAttribute('class') || '')) + .filter(e => { const r = e.getBoundingClientRect(); + return r.width < 90 && r.height < 90 && r.y > tr.y && r.x > tr.x + tr.width - 260; }) + .sort((a, b) => b.getBoundingClientRect().x - a.getBoundingClientRect().x)[0] || null; +} +function __sendBox() { + const el = __sendButton(); + if (!el) return null; + const r = el.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; +} +// Идёт ли генерация. Пока DeepSeek печатает, та же кнопка превращается в +// «стоп» и остаётся активной, ХОТЯ поле ввода уже пустое. В покое при пустом +// поле она помечена ds-button--disabled. Отсюда признак: +// пусто + кнопка активна = печатает +// пусто + кнопка погашена = готово +// Замерено 06.08.2026: класс --disabled пропал на 3-й секунде ответа и +// вернулся на 42-й, ровно когда текст перестал расти. +function __isGenerating() { + const b = __sendButton(); + if (!b) return false; + const off = /ds-button--disabled/.test(b.getAttribute('class') || ''); + return __inputEmpty() && !off; +} +// Пусто ли поле ввода — единственный честный признак, что сообщение ушло. +function __inputEmpty() { + const el = __input(); + if (!el) return true; + const v = el.tagName.toLowerCase() === 'textarea' ? el.value : el.innerText; + return !String(v || '').trim(); +} +// В innerText попадает служебная обвязка страницы: у каждого блока кода — +// подписи кнопок "Copy" и "Download" отдельными строками, у ответов поискового +// чата — номера сносок ("5", "8" и голые дефисы между ними). Всё это ломает +// код при вставке и раздувает ответ. Чистим построчно: трогаем только строки, +// целиком состоящие из мусора, поэтому содержимое кода не страдает. +function __cleanAnswer(raw) { + const lines = String(raw || '').split('\\n'); + const out = []; + for (let i = 0; i < lines.length; i++) { + const t = lines[i].trim(); + if (/^(Copy|Download|Копировать|Скачать)$/i.test(t)) continue; + // Сноски поискового ответа: номера источников и дефисы-разделители едут + // отдельными строками («6», «12», «-») и рвут текст на середине фразы. + // Убираем только строку ЦЕЛИКОМ из номера или дефиса — числа внутри + // предложений и нумерованные списки («1.») это не задевает. + if (/^\\d{1,3}$/.test(t) || /^[-–—.]$/.test(t)) { + const prev = (out[out.length - 1] || '').trim(); + const next = (lines[i + 1] || '').trim(); + // Строка-одиночка среди текста — сноска. Если вокруг пусто, значит это + // самостоятельная строка ответа, её не трогаем. + if (prev || next) continue; + } + out.push(lines[i]); + } + return out.join('\\n').replace(/\\n{3,}/g, '\\n\\n').trim(); +} +function __lastAnswer() { + const { nodes } = __answerNodes(); + if (!nodes.length) return null; + return __cleanAnswer(nodes[nodes.length - 1].innerText); +} +function __answerCount() { return __answerNodes().nodes.length; } +// Кнопка Continue под ответом: DeepSeek обрывает длинный ответ и предлагает +// дописать. Текст продолжения уходит В ТОТ ЖЕ блок ответа (проверено +// 11.08.2026: 32 073 → 46 422 символов, число блоков не изменилось), поэтому +// склеивать ничего не нужно — достаточно нажать и ждать дальше. +// Искать только среди КНОПОК. Поиск по тексту где угодно ловит ключевое слово +// continue внутри блока кода: подсветка оборачивает его в отдельный span, и +// текст такого span равен ровно "continue". Проверено 11.08.2026 — мост +// десять раз подряд кликал по слову в коде вместо кнопки и упирался в предел. +// Ответ с кодом на JavaScript почти всегда содержит такое слово, так что это +// не редкий случай, а обычный. +// Селектор строго '.ds-button': маска [class*="ds-button"] цепляет вложенный +// ds-button__content, и клик уходит мимо — обработчик висит на самой кнопке. +// Кнопка Retry: DeepSeek иногда отвечает «Server is busy. Try again later, or +// use Instant Mode.» вместо ответа и рисует круглую оранжевую кнопку ↻ слева +// от сообщения. Подписи у неё нет, зато модификатор ds-button--warning на +// странице только у неё (проверено 12.08.2026). Ждать в такой ситуации +// бессмысленно: генерация не идёт, и мост честно упирался в таймаут. +function __retryBox() { + const busy = /server is busy|сервер занят|try again later/i.test(document.body.innerText || ''); + const btn = [...document.querySelectorAll('[role="button"], button')] + .filter(__visible) + .find(e => /\\bds-button--warning\\b/.test(e.getAttribute('class') || '')); + if (!btn || !busy) return null; + const r = btn.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; +} +function __continueBox() { + const btn = [...document.querySelectorAll('.ds-button')] + .filter(__visible) + .filter(e => !e.closest('.ds-markdown')) // не из текста ответа + .find(e => /^(continue|продолжить)$/i.test((e.innerText || '').trim())); + if (!btn) return null; + btn.scrollIntoView({ block: 'center' }); + const r = btn.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; +} +`; + +// Вставка текста в React-поле: нативный сеттер + событие input, +// иначе React не заметит изменения и отправит пустое сообщение. +const SET_TEXT = (text) => `(() => { ${PAGE_HELPERS} + const el = __input(); + if (!el) return { ok: false, reason: 'поле ввода не найдено' }; + const value = ${JSON.stringify(text)}; + el.focus(); + if (el.tagName.toLowerCase() === 'textarea') { + const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; + setter.call(el, value); + el.dispatchEvent(new Event('input', { bubbles: true })); + } else { + el.textContent = value; + el.dispatchEvent(new InputEvent('input', { bubbles: true, data: value })); + } + // textBefore снимается ДО отправки: по нему waitForAnswer поймёт, что ответ + // сменился, даже если новый успел напечататься раньше первого замера. + return { ok: true, tag: el.tagName.toLowerCase(), + before: __answerCount(), textBefore: __lastAnswer() }; +})()`; + +async function pressEnter(s) { + const base = { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 }; + await s.send('Input.dispatchKeyEvent', { ...base, text: '\r' }); + await s.send('Input.dispatchKeyEvent', { ...base, type: 'keyUp' }); +} + +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +async function mouseClickAt(s, box) { + const p = { x: box.x, y: box.y, button: 'left', clickCount: 1 }; + await s.send('Input.dispatchMouseEvent', { ...p, type: 'mouseMoved', button: 'none', clickCount: 0 }); + await sleep(120); + await s.send('Input.dispatchMouseEvent', { ...p, type: 'mousePressed' }); + await s.send('Input.dispatchMouseEvent', { ...p, type: 'mouseReleased' }); +} + +// Enter срабатывает не всегда: текст остаётся висеть в поле, а мы уходим ждать +// ответ, которого не будет. Поэтому отправку ПОДТВЕРЖДАЕМ — поле должно опустеть, +// иначе жмём саму кнопку настоящим мышиным кликом. +async function sendAndConfirm(s, log) { + const empty = () => s.eval(`(() => { ${PAGE_HELPERS} return __inputEmpty(); })()`); + + const ways = [ + async () => { await pressEnter(s); return 'Enter'; }, + // DOM-вызов click() на самой кнопке. Оказался надёжнее всего: срабатывает + // и тогда, когда Enter и настоящий мышиный клик молчат — например, если + // окно браузера свёрнуто или неактивно и события ввода до страницы не + // доходят. Проверено 06.08.2026: поле опустело за 500 мс после того, как + // оба других способа подряд не сработали. + async () => { + const r = await s.eval(`(() => { ${PAGE_HELPERS} + const b = __sendButton(); if (!b) return false; b.click(); return true; })()`); + return r ? 'DOM-клик по кнопке отправки' : null; + }, + async () => { + const box = await s.eval(`(() => { ${PAGE_HELPERS} return __sendBox(); })()`); + if (!box) return null; + await mouseClickAt(s, box); + return 'мышиный клик по кнопке отправки'; + }, + async () => { await pressEnter(s); return 'Enter (повтор)'; }, + ]; + + for (const way of ways) { + const how = await way(); + if (!how) continue; + for (let i = 0; i < 12; i++) { // до ~3 с на опустошение поля + await sleep(250); + if (await empty()) { log(`отправлено: ${how}`); return; } + } + log(`${how} не сработал — поле всё ещё занято, пробую иначе`); + } + throw new Error( + 'Сообщение не ушло: текст остался в поле ввода.\n' + + 'Посмотрите вкладку — возможна капча, лимит сообщений или требуется вход.'); +} + +// Задать вопрос ПРАВКОЙ первого сообщения вместо отправки нового. +// Чат остаётся из одного обмена: контекст не растёт, мусор не копится, +// приставка из seed-prompt.md уезжает вместе с каждым вопросом. +async function editFirstAndSend(s, text, log) { + const box = await s.eval(`(() => { ${PAGE_HELPERS} + const it = __firstMsg(); + if (!it) return null; + it.scrollIntoView({ block: 'center' }); + const r = it.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`); + if (!box) throw new Error('первое сообщение чата не найдено (нет [data-virtual-list-item-key="1"])'); + + // наведение обязательно: без него кнопок под сообщением в DOM нет + await s.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: box.x, y: box.y, button: 'none', clickCount: 0 }); + await sleep(700); + + const ed = await s.eval(`(() => { ${PAGE_HELPERS} return __clickEdit(); })()`); + if (!ed.ok) throw new Error('не удалось открыть правку: ' + ed.reason); + await sleep(900); + + const set = await s.eval(`(() => { ${PAGE_HELPERS} return __setEditText(${JSON.stringify(text)}); })()`); + if (!set.ok) throw new Error(set.reason); + log(`правка первого сообщения: ${set.len} символов`); + await sleep(400); + + const state = await s.eval(`(() => { ${PAGE_HELPERS} + return { before: __answerCount(), textBefore: __lastAnswer() }; })()`); + await pressEnter(s); + for (let i = 0; i < 16; i++) { + await sleep(300); + const gone = await s.eval(`(() => { ${PAGE_HELPERS} return !__editArea(); })()`); + if (gone) { log('отправлено правкой'); return state; } + } + throw new Error('правка не отправилась: поле правки всё ещё открыто'); +} + +// Открыть чат по id из chats.json — переходом по адресу, а не кликом в сайдбаре: +// названия чатов DeepSeek меняет сам, а id из URL постоянен. +async function openChatById(s, id, log) { + const cur = await s.eval(`(() => { ${PAGE_HELPERS} return __chatId(); })()`); + if (cur === id) { log(`чат: уже открыт (${id})`); return; } + await s.send('Page.enable').catch(() => { }); + await s.send('Page.navigate', { url: `https://${CHAT_HOST}/a/chat/s/${id}` }); + for (let i = 0; i < 40; i++) { // до ~20 с на загрузку SPA + await sleep(500); + const ok = await s.eval(`(() => { ${PAGE_HELPERS} + return __chatId() === ${JSON.stringify(id)} && !!__input(); })()`).catch(() => false); + if (ok) { await sleep(800); log(`чат: ${id}`); return; } + } + throw new Error(`не удалось открыть чат ${id} — страница не загрузилась.`); +} + +// Настоящий клик мышью по координатам: el.click() у DeepSeek только красит +// тумблер, но модель чата не меняет — проверяется по индикатору Instant/DeepThink. +async function realClick(s, name) { + const box = await s.eval(`(() => { ${PAGE_HELPERS} + const el = [...document.querySelectorAll('div[role="button"], button, [class*="toggle-button"]')] + .filter(__visible) + .filter(e => !/toggle-button__/.test(e.getAttribute('class') || '')) + .find(e => (e.innerText || '').trim().toLowerCase() === ${JSON.stringify(name)}.toLowerCase()); + if (!el) return null; + const r = el.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`); + if (!box) return false; + const p = { x: box.x, y: box.y, button: 'left', clickCount: 1 }; + await s.send('Input.dispatchMouseEvent', { ...p, type: 'mouseMoved', button: 'none', clickCount: 0 }); + await sleep(120); + await s.send('Input.dispatchMouseEvent', { ...p, type: 'mousePressed' }); + await s.send('Input.dispatchMouseEvent', { ...p, type: 'mouseReleased' }); + await sleep(600); + return true; +} + +// Клик мышью по видимому элементу с ТОЧНЫМ текстом (пункт меню моделей). +async function clickExactText(s, text) { + const box = await s.eval(`(() => { ${PAGE_HELPERS} + const el = [...document.querySelectorAll('div, span, li, button')].filter(__visible) + .filter(e => (e.innerText || '').trim() === ${JSON.stringify(text)}) + .sort((a, b) => a.getBoundingClientRect().width - b.getBoundingClientRect().width)[0]; + if (!el) return null; + const r = el.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`); + if (!box) return false; + const p = { x: box.x, y: box.y, button: 'left', clickCount: 1 }; + await s.send('Input.dispatchMouseEvent', { ...p, type: 'mouseMoved', button: 'none', clickCount: 0 }); + await sleep(120); + await s.send('Input.dispatchMouseEvent', { ...p, type: 'mousePressed' }); + await s.send('Input.dispatchMouseEvent', { ...p, type: 'mouseReleased' }); + await sleep(700); + return true; +} + +// У DeepSeek два независимых механизма: выпадающий выбор МОДЕЛИ +// (Instant / Expert / Vision) и тумблеры DeepThink / Search поверх неё. +async function ensureModel(s, wantModel, wantToggle, log) { + const read = async () => (await s.eval(`(() => { ${PAGE_HELPERS} return __modelLabel(); })()`))[0] || '?'; + + for (let attempt = 1; attempt <= 3; attempt++) { + const cur = await read(); + if (cur.toLowerCase() === wantModel.toLowerCase()) break; + if (attempt === 3) { + throw new Error(`не удалось выбрать модель ${wantModel} — выбрана ${cur}.`); + } + const box = await s.eval(`(() => { ${PAGE_HELPERS} + const el = document.querySelector('[data-model-type="${wantModel}"][role="radio"]'); + if (!el || !__visible(el)) return null; + const r = el.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`); + if (!box) throw new Error(`переключатель модели ${wantModel} не найден (чат уже начат?)`); + const p = { x: box.x, y: box.y, button: 'left', clickCount: 1 }; + await s.send('Input.dispatchMouseEvent', { ...p, type: 'mouseMoved', button: 'none', clickCount: 0 }); + await sleep(120); + await s.send('Input.dispatchMouseEvent', { ...p, type: 'mousePressed' }); + await s.send('Input.dispatchMouseEvent', { ...p, type: 'mouseReleased' }); + await sleep(700); + log(`выбор модели: попытка ${attempt} → ${await read()}`); + } + + if (wantToggle) { + const before = await s.eval(`(() => { ${PAGE_HELPERS} return __toggles(); })()`); + const t = before.find(x => x.name.toLowerCase() === wantToggle.toLowerCase()); + if (t && !t.on) { + await realClick(s, wantToggle); + const after = await s.eval(`(() => { ${PAGE_HELPERS} return __toggles(); })()`); + const now = after.find(x => x.name.toLowerCase() === wantToggle.toLowerCase()); + log(`тумблер ${wantToggle}: ${now?.on ? 'включён' : 'НЕ включился'}`); + } else if (t?.on) { + log(`тумблер ${wantToggle}: уже включён`); + } + } + + const model = await read(); + const toggles = await s.eval(`(() => { ${PAGE_HELPERS} return __toggles(); })()`); + const on = toggles.filter(x => x.on).map(x => x.name); + return on.length ? `${model}+${on.join('+')}` : model; +} + +// Ответ считается готовым, когда его текст перестал меняться. +// Ответ считается готовым, только когда страница ПЕРЕСТАЛА печатать. +// Раньше признаком был «текст не менялся 2,5 с», и это врало: в режиме expert +// DeepSeek думает паузами, любая заминка длиннее порога выглядела как конец. +// Замерено 06.08.2026 на одном ответе: наружу ушло 640 символов, тогда как +// ответ дорос до 4135 — терялось шесть седьмых текста вместе со всем кодом, +// причём молча. Теперь ждём, пока __isGenerating() не станет false. +// stallSec — НЕ общий срок на ответ, а максимум БЕЗДЕЙСТВИЯ. Пока страница +// печатает или текст растёт, ждём сколько угодно: длинный ответ на 13 КБ +// дописывался 240 с и старый общий таймаут рубил его на живом месте. Срок +// отсчитывается от последнего признака жизни, поэтому срабатывает только +// там, где и должен — капча, разлогин, зависшая вкладка, пустой экран. +// Ответ дописан — но в DOM его ещё нет целиком. Блоки кода DeepSeek рисует +// лениво, а браузер работает В ФОНЕ: окно неактивно, страница не +// отрисовывается, и содержимое блоков в DOM просто не создаётся. От него +// остаются первые две строки-заглушки, и никакой textContent остального не +// достанет — данных нет. +// +// Прокрутка не помогает (проверено: проход по всему ответу шагами не дал ни +// одного лишнего символа). Помогает единственное — заставить браузер +// отрисовать страницу, а через CDP это делает Page.captureScreenshot. +// Замерено 11.08.2026 на одном ответе: 3136 символов до съёмки, 30 817 после. +// Картинка не нужна и выбрасывается, поэтому берём самую дешёвую — jpeg +// с качеством 1. +async function revealAnswer(s, current, log) { + let best = current || ''; + for (let i = 0; i < 3; i++) { + try { await s.send('Page.captureScreenshot', { format: 'jpeg', quality: 1 }); } + catch { break; } // отрисовка недоступна — отдаём что есть + await sleep(350); + const full = await s.eval(`(() => { ${PAGE_HELPERS} return __lastAnswer(); })()`); + if (!full || full.length <= best.length) break; // больше не растёт + if (log) log(`после отрисовки ответ вырос: ${best.length} → ${full.length} символов`); + best = full; + } + return best; +} + +async function waitForAnswer(s, countBefore, stallSec, log, textBefore = null) { + const STABLE_MS = 2500; + const MAX_CONTINUE = 10; // предел на случай, если кнопка не гаснет + const MAX_RETRY = 5; // «Server is busy» — сколько раз пробовать снова + let continues = 0; + let retries = 0; + let lastText = null; + // Что было на экране ДО отправки. Раньше это снималось при первом замере, уже + // после — и быстрый ответ (меньше 600 мс) выглядел как «ничего не изменилось»: + // grew не взводился, и команда честно ждала весь таймаут, приписывая + // «замерло», хотя ответ давно лежал на экране. Поймано на «столица + // Португалии»: ответ пришёл мгновенно, ожидание длилось 240 с. + let firstText = textBefore; + let lastChange = Date.now(); // когда текст в последний раз менялся + let lastProgress = Date.now(); // когда были ЛЮБЫЕ признаки жизни + let grew = false; + + for (;;) { + await sleep(600); + const state = await s.eval(`(() => { ${PAGE_HELPERS} + return { count: __answerCount(), text: __lastAnswer(), generating: __isGenerating() }; })()`); + + const text = state.text ?? ''; + if (firstText === null) firstText = text; + // При ПРАВКЕ первого сообщения число ответов не растёт: старый заменяется + // новым. Поэтому признаком «ответ пошёл» служит любой из трёх: появился + // новый блок, началась генерация, текст отличается от исходного. + if (state.count > countBefore || state.generating || text !== firstText) grew = true; + + if (text !== lastText) { + lastText = text; lastChange = Date.now(); lastProgress = Date.now(); + continue; + } + if (state.generating) { // пауза в размышлении — это не конец + lastChange = Date.now(); lastProgress = Date.now(); + continue; + } + // «Server is busy» — ответа не будет вовсе, ждать нечего. Жмём Retry и + // ждём заново. Проверяем ДО условия стабильности: текста может не быть + // совсем, и тогда мост просто досиживал бы до таймаута. + if (!state.generating && retries < MAX_RETRY) { + const retry = await s.eval(`(() => { ${PAGE_HELPERS} return __retryBox(); })()`); + if (retry) { + retries++; + if (log) log(`сервер занят — повторяю (${retries}/${MAX_RETRY})`); + await sleep(3000); // сразу долбить занятый сервер смысла нет + await hoverClick(s, retry); + await sleep(2000); + lastChange = Date.now(); lastProgress = Date.now(); + continue; + } + } + + if (grew && text && Date.now() - lastChange >= STABLE_MS) { + // Печатать перестали — но это может быть не конец ответа, а обрыв по + // лимиту с предложением дописать. Пока кнопка есть, жмём её и ждём + // дальше: продолжение уходит в тот же блок, склейка не нужна. + const box = continues < MAX_CONTINUE + ? await s.eval(`(() => { ${PAGE_HELPERS} return __continueBox(); })()`) + : null; + if (box) { + await sleep(400); // дать прокрутке доехать + await hoverClick(s, box); + continues++; + if (log) log(`нажата кнопка Continue (${continues}) — ответ был оборван`); + await sleep(1500); + lastChange = Date.now(); lastProgress = Date.now(); + continue; + } + return revealAnswer(s, text, log); + } + + if (Date.now() - lastProgress >= stallSec * 1000) { + // И на аварийном выходе текст снимаем после прокрутки: иначе к «замерло» + // добавится ещё и потеря блоков кода, а причину потом не разделить. + if (lastText) return (await revealAnswer(s, lastText, log)) + + `\n\n[замерло: ${stallSec} с без признаков генерации — ответ может быть неполным]`; + throw new Error( + `Ответ не появился, страница ${stallSec} с не подаёт признаков жизни.\n` + + 'Проверьте вкладку: возможна капча, лимит сообщений или требуется вход.'); + } + } +} + +// Навести мышь и щёлкнуть настоящим мышиным событием. +async function hoverClick(s, box) { + await s.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: box.x, y: box.y, button: 'none', clickCount: 0 }); + await sleep(250); + const p = { x: box.x, y: box.y, button: 'left', clickCount: 1 }; + await s.send('Input.dispatchMouseEvent', { ...p, type: 'mousePressed' }); + await s.send('Input.dispatchMouseEvent', { ...p, type: 'mouseReleased' }); +} + +// Новый чат. Единственное место, где это делается: раньше клик был внутри +// помощника страницы и его беззвучный отказ никто не замечал. Теперь ждём +// подтверждения адресом — из старого чата мы обязаны уйти. +async function clickNewChat(s, log = () => {}) { + await pressEscape(s); + await repaint(s); + const was = await s.eval(`(() => { ${PAGE_HELPERS} return __chatId(); })()`); + const box = await s.eval(`(() => { ${PAGE_HELPERS} return __newChatBox(); })()`); + if (!box) throw new Error('кнопка New chat не найдена'); + + await hoverClick(s, box); + // Пустой чат адреса не имеет: __chatId() становится null. Ждём именно смены. + for (let i = 0; i < 20; i++) { + await sleep(300); + const now = await s.eval(`(() => { ${PAGE_HELPERS} return __chatId(); })()`); + if (now !== was) { log('открыт новый чат'); return; } + } + throw new Error('нажал New chat, но чат не сменился — вкладка не откликнулась'); +} + +// Заставить браузер перерисовать страницу. У окна в фоне рисование стоит, и +// то, что появляется по :hover — кнопка «…» у строки чата, пункты меню — в DOM +// просто не создаётся. Через CDP единственный способ вынудить отрисовку — +// снять кадр; сама картинка не нужна, поэтому берём самую дешёвую. +// Тот же корень, что у пропадавших блоков кода (см. revealAnswer). +async function repaint(s) { + try { await s.send('Page.captureScreenshot', { format: 'jpeg', quality: 1 }); } + catch { /* не вышло — работаем как есть */ } +} + +// Закрыть открытое меню: иначе следующий клик попадёт в него, а не в строку. +async function pressEscape(s) { + for (const type of ['keyDown', 'keyUp']) + await s.send('Input.dispatchKeyEvent', { type, key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27, nativeVirtualKeyCode: 27 }); + await sleep(350); +} + +// Удалить ОДИН чат по id. Порядок шагов проверен на 12 чатах подряд: навести +// мышь на строку (без этого у неактивного чата точек в DOM нет), щёлкнуть +// "...", выбрать Delete, подтвердить "Delete chat". Адресуемся строго по id: +// порядок чатов в сайдбаре меняется, а у активного чата кнопка видна всегда — +// на этом уже удалялся не тот чат. +async function deleteChat(s, id, log = () => {}) { + await pressEscape(s); + await repaint(s); + const row = await s.eval(`(() => { ${PAGE_HELPERS} return __chatRowBox(${JSON.stringify(id)}); })()`); + if (!row) return true; // уже нет — считаем удалённым + + await s.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: row.x, y: row.y, button: 'none', clickCount: 0 }); + await sleep(450); + await repaint(s); // без этого кнопки «…» в DOM нет + const dots = await s.eval(`(() => { ${PAGE_HELPERS} return __dotsBox(${row.top}, ${row.bottom}); })()`); + if (!dots) return false; + + await hoverClick(s, dots); + await sleep(600); + await repaint(s); + const del = await s.eval(`(() => { ${PAGE_HELPERS} return __menuItemBox(/^(delete|удалить)$/i); })()`); + if (!del) return false; + + await hoverClick(s, del); + await sleep(800); + await repaint(s); + const yes = await s.eval(`(() => { ${PAGE_HELPERS} return __confirmBox(); })()`); + if (yes) { await hoverClick(s, yes); await sleep(1000); } + + const gone = await s.eval(`(() => { ${PAGE_HELPERS} + return !__chatIds().some(c => c.id === ${JSON.stringify(id)}); })()`); + if (gone) log(`удалён чат ${id}`); + return gone; +} + +// Убрать из сайдбара всё, чего нет в chats.json. Вкладка DeepSeek — рабочее +// место моста, а не хранилище переписки: отработавшие чаты копились без счёта +// (за сутки работы Эмилии их набралось семнадцать) и мешали искать нужный. +// Стоит недорого: список чатов — один eval, и если лишнего нет, дальше ничего +// не происходит. +async function pruneChats(s, log = () => {}) { + let keep = new Set(); + try { + const map = JSON.parse(await readFile(CHATS_FILE, 'utf8')); + keep = new Set(Object.values(map).map(v => v && v.id).filter(Boolean)); + } catch { return 0; } // нет chats.json — не знаем, что беречь, не трогаем + if (!keep.size) return 0; + + const all = await s.eval(`(() => { ${PAGE_HELPERS} return __chatIds(); })()`); + const extra = all.filter(c => !keep.has(c.id)); + if (!extra.length) return 0; + + log(`лишних чатов в сайдбаре: ${extra.length}, убираю`); + let done = 0; + for (const c of extra) { + try { if (await deleteChat(s, c.id)) done++; } + catch (e) { log(`не удалось удалить «${c.title}»: ${e.message}`); } + } + log(`удалено чатов: ${done}`); + return done; +} + +// Роль → настройки нового чата. Тот же список, что использует init. +const CHAT_PLAN = { + think: { model: 'expert', toggle: null, hello: 'Рабочий чат для рассуждений. Отвечай кратко.' }, + search: { model: 'default', toggle: 'Search', hello: 'Рабочий чат для поиска в интернете. Отвечай кратко.' }, + vision: { model: 'vision', toggle: null, hello: 'Рабочий чат для разбора картинок. Отвечай кратко.' }, +}; + +// DeepSeek разрешает править первое сообщение лишь ШЕСТЬ раз, дальше кнопка +// правки перестаёт открывать поле. Поэтому чат — расходник: как только правки +// кончились, заводим новый с тем же режимом и переписываем chats.json. +// +// firstText — настоящий вопрос вместо приветствия. Раньше чат +// всегда открывался служебной фразой, а вопрос уходил ПРАВКОЙ этой фразы: круг +// DeepSeek тратился впустую и одна правка из шести — тоже. Режим чата и его id +// появляются от любого первого сообщения, так что якорь не нужен. +// Возвращает { id, answered } — answered означает, что ответ на firstText уже +// получен и вызывающему остаётся его забрать, а не спрашивать заново. +async function makeChat(s, role, log, firstText = null, timeout = 300) { + const step = CHAT_PLAN[role] || CHAT_PLAN.think; + await clickNewChat(s, log); + await sleep(1500); + + await ensureModel(s, step.model, step.toggle, log); + const put = await s.eval(SET_TEXT(firstText || step.hello)); + if (!put.ok) throw new Error(put.reason); + await sleep(300); + await sendAndConfirm(s, log); + const answer = await waitForAnswer(s, put.before, + firstText ? timeout : 420, log, put.textBefore); + + // Ждём id, отличный от прежнего. Просто «непустой» тут мало: если переход + // не состоялся, в адресе остаётся старый id, и он выглядит как новый. + const mapFile = CHATS_FILE; + let prev = null; + try { prev = JSON.parse(await readFile(mapFile, 'utf8'))[role]?.id ?? null; } catch { /* нет файла */ } + + let id = null; + for (let i = 0; i < 20; i++) { + id = await s.eval(`(() => { ${PAGE_HELPERS} return __chatId(); })()`); + if (id && id !== prev) break; + id = null; + await sleep(500); + } + if (!id) throw new Error('новый чат создан, но его id не появился в адресе'); + + const file = mapFile; + let map = {}; + try { map = JSON.parse(await readFile(file, 'utf8')); } catch { /* заведём заново */ } + + // Чат — расходник, и раньше отработавшие копились в сайдбаре без счёта: за + // сутки работы Эмилии их набралось семнадцать. Удаляем свой прежний чат этой + // роли сразу, как только новый создан и записан. + const old = map[role] && map[role].id; + map[role] = { id, mode: step.toggle ? `${step.model}+${step.toggle}` : step.model }; + await writeFile(file, JSON.stringify(map, null, 2), 'utf8'); + log(`создан новый чат ${role}: ${id}`); + + if (old && old !== id) { + try { await deleteChat(s, old, log); } + catch (e) { log(`старый чат ${old} удалить не вышло: ${e.message}`); } + } + return { id, answer, answered: !!firstText }; +} + +// ================================================================== +// Публичный вход +// ================================================================== + +// Флаги окна не украшение. Замер в DeepShim 11.08.2026: у неактивного окна +// Windows сообщает браузеру, что оно перекрыто, рисование прекращается, а +// DeepSeek рисует блоки кода лениво — ответ приезжает без кода (3136 символов +// вместо 30 817). Эти флаги запрещают браузеру засыпать. +const BROWSER_FLAGS = [ + '--remote-allow-origins=*', + '--start-maximized', + '--disable-backgrounding-occluded-windows', + '--disable-renderer-backgrounding', + '--disable-background-timer-throttling', + '--disable-features=CalculateNativeWinOcclusion', +]; + +export async function browserAlive() { + try { + const r = await fetch(`http://127.0.0.1:${PORT}/json/version`, + { signal: AbortSignal.timeout(3000) }); + return r.ok; + } catch { return false; } +} + +// Поднять браузер, если он не отвечает, и открыть вкладку с чатом, если её нет. +export async function ensureBrowser(log = () => {}) { + if (!(await browserAlive())) { + if (!BROWSER.exe || !BROWSER.profileDir) { + throw new Error( + `Браузер не отвечает на порту ${PORT}, и запустить его сам не могу: ` + + 'в config.json не задан browserExe или profileDir.'); + } + if (!existsSync(BROWSER.exe)) { + throw new Error(`Браузер не найден: ${BROWSER.exe}. Проверьте browserExe в config.json.`); + } + log('Браузер не отвечает, поднимаю...'); + spawn(BROWSER.exe, + [`--user-data-dir=${BROWSER.profileDir}`, + `--remote-debugging-port=${PORT}`, + ...BROWSER_FLAGS, BROWSER.url], + { detached: true, stdio: 'ignore' }).unref(); + // Холодный профиль поднимается неспешно; 60 секунд с запасом. + for (let i = 0; i < 30 && !(await browserAlive()); i++) await sleep(2000); + if (!(await browserAlive())) { + throw new Error( + `Браузер так и не открыл отладочный порт ${PORT}. Скорее всего этот ` + + 'профиль уже открыт БЕЗ отладочного порта: новый процесс отдаёт ссылку ' + + `старому окну и завершается. Закройте окно с профилем ${BROWSER.profileDir} ` + + 'и повторите.'); + } + } + + const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json().catch(() => []); + if (list.some(t => String(t.url || '').includes('deepseek'))) return; + // Новая вкладка в свежих сборках Chromium открывается ТОЛЬКО методом PUT: + // на GET протокол отвечает 405 и молча ничего не делает. + const r = await fetch( + `http://127.0.0.1:${PORT}/json/new?url=${encodeURIComponent(BROWSER.url)}`, + { method: 'PUT' }); + if (!r.ok) throw new Error(`Не удалось открыть вкладку DeepSeek: ${r.status}.`); + await sleep(4000); // дать чату прогрузиться до первого вопроса +} + +// Картинка приезжает к нам в памяти, а странице нужен файл на диске: другого +// способа наполнить у протокола нет. +async function attachImage(s, bytes, log) { + const { mkdtemp, writeFile: wf, rm } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const dir = await mkdtemp(join(tmpdir(), 'dsapi-img-')); + const file = resolvePath(join(dir, 'image.jpg')); + await wf(file, bytes); + try { + await s.send('DOM.enable'); + const { root } = await s.send('DOM.getDocument'); + const { nodeId } = await s.send('DOM.querySelector', + { nodeId: root.nodeId, selector: 'input[type="file"]' }); + if (!nodeId) throw new Error('поле загрузки файла на странице не найдено'); + await s.send('DOM.setFileInputFiles', { files: [file], nodeId }); + log('приложена картинка'); + await sleep(2500); // дать чату загрузить вложение + } finally { + // Файл нужен только на время загрузки; страница читает его сразу. + setTimeout(() => rm(dir, { recursive: true, force: true }).catch(() => {}), 30000); + } +} + +// Один вопрос — один ответ. Всё остальное (какой чат, жив ли он, не кончились +// ли правки) решается здесь и наружу не торчит. +export async function ask({ prompt, role = 'think', imageBytes = null, + timeout = 300, log = () => {} }) { + if (!String(prompt || '').trim()) throw new Error('пустой вопрос'); + if (!ROLES.includes(role)) role = 'think'; + await ensureBrowser(log); + + return withChat(async (s) => { + await pruneChats(s, log); // сайдбар держим чистым + + const known = (await chatsMap())[role]?.id ?? null; + const alive = known && await s.eval(`(() => { ${PAGE_HELPERS} + return __chatIds().some(c => c.id === ${JSON.stringify(String(known))}); })()`); + + // Чата нужного режима нет — заводим сразу с вопросом. Отдельное приветствие + // стоило бы лишнего круга DeepSeek и одной правки из шести, а нужно не было: + // режим и id чата появляются от любого первого сообщения. + if (!alive) { + log(`чат ${role} не найден, создаю сразу с вопросом`); + const made = await makeChat(s, role, log, prompt, timeout); + return made.answer; + } + + await openChatById(s, String(known), log); + if (imageBytes) await attachImage(s, imageBytes, log); + + // С картинкой — только новым сообщением: вложение попадает в поле ввода, + // а правка идёт в отдельном поле над ним, и картинка туда не переезжает. + const canEdit = !imageBytes && await s.eval( + `(() => { ${PAGE_HELPERS} return !!__firstMsg(); })()`); + + if (canEdit) { + try { + const sent = await editFirstAndSend(s, prompt, log); + return await waitForAnswer(s, sent.before, timeout, log, sent.textBefore); + } catch (e) { + // Единственная ожидаемая причина — исчерпан лимит правок (их шесть). + if (!/поле правки не открылось/.test(String(e.message || e))) throw e; + log('правки в этом чате кончились, завожу новый сразу с вопросом'); + const made = await makeChat(s, role, log, prompt, timeout); + return made.answer; + } + } + + const put = await s.eval(SET_TEXT(prompt)); + if (!put.ok) throw new Error(put.reason); + await sleep(250); + await sendAndConfirm(s, log); + return waitForAnswer(s, put.before, timeout, log, put.textBefore); + }); +} + +// Диагностика: что видно во вкладке. Используется эндпойнтом /api/probe. +export async function probe() { + await ensureBrowser(); + return withChat(async (s, tab) => { + const info = await s.eval(`(() => { ${PAGE_HELPERS} + const el = __input(); + const a = __answerNodes(); + return { + input: !!el, + answerBlocks: a.nodes.length, + generating: __isGenerating(), + chats: __chatIds(), + needsLogin: /log ?in|войти|sign in/i.test(document.body.innerText.slice(0, 3000)), + }; + })()`); + return { tab: tab.url, ...info, chatsFile: await chatsMap() }; + }); +} diff --git a/chats.example.json b/chats.example.json new file mode 100644 index 0000000..297b62d --- /dev/null +++ b/chats.example.json @@ -0,0 +1,14 @@ +{ + "think": { + "id": "00000000-0000-0000-0000-000000000000", + "mode": "expert" + }, + "search": { + "id": "00000000-0000-0000-0000-000000000000", + "mode": "default+Search" + }, + "vision": { + "id": "00000000-0000-0000-0000-000000000000", + "mode": "vision" + } +} diff --git a/config.json b/config.json index eef8004..9ea60a8 100644 --- a/config.json +++ b/config.json @@ -1,7 +1,9 @@ { "port": 11500, "host": "0.0.0.0", - "bridge": "C:/#Projects/ollama_orchestro/ask.mjs", + "cdpPort": 9333, + "browserExe": "C:\\Program Files\\Yandex\\YandexBrowser\\Application\\browser.exe", + "profileDir": "C:\\Browsers\\deepseek", "chatUrl": "https://chat.deepseek.com/", "defaultRole": "think", "timeout": 300 diff --git a/server.mjs b/server.mjs index 615ae12..47a04d4 100644 --- a/server.mjs +++ b/server.mjs @@ -6,146 +6,43 @@ // и отдаёт наружу протокол Ollama: /api/tags и /api/chat. Любая программа, // которая уже умеет ходить в Ollama, подключается сюда сменой одного адреса. // -// Что внутри происходит: запрос → файл → мост ollama_orchestro/ask.mjs → -// правка первого сообщения в чате DeepSeek → ответ → файл → HTTP. +// Здесь только HTTP и перевод формата. Всё, что касается браузера и разметки +// DeepSeek, живёт в browser.mjs. // -// Зависимостей нет: Node 22+ со встроенным fetch. +// Зависимостей нет: Node 22+ со встроенными fetch и WebSocket. import { createServer } from 'node:http'; -import { spawn } from 'node:child_process'; -import { readFile, writeFile, rm, mkdtemp } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; +import { readFile } from 'node:fs/promises'; import { join, dirname } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -// fileURLToPath, а не разбор pathname вручную: путь проекта содержит «#», +import { configure, ask, probe, browserAlive, ROLES } from './browser.mjs'; + +// fileURLToPath, а не разбор pathname вручную: путь проекта может содержать «#», // и в URL он приезжает как %23 — самодельный разбор ломается именно на нём. const HERE = dirname(fileURLToPath(import.meta.url)); const cfg = { port: 11500, host: '0.0.0.0', - bridge: 'C:/#Projects/ollama_orchestro/ask.mjs', + cdpPort: 9333, + browserExe: '', + profileDir: '', chatUrl: 'https://chat.deepseek.com/', defaultRole: 'think', timeout: 300, ...JSON.parse(await readFile(join(HERE, 'config.json'), 'utf8').catch(() => '{}')), + // Личные пути — сюда: этот файл в репозиторий не попадает, и обновление + // проекта не затрёт настройки конкретной машины. + ...JSON.parse(await readFile(join(HERE, 'config.local.json'), 'utf8').catch(() => '{}')), }; if (process.env.DEEPSEEK_API_PORT) cfg.port = Number(process.env.DEEPSEEK_API_PORT); +if (process.env.DEEPSEEK_CDP_PORT) cfg.cdpPort = Number(process.env.DEEPSEEK_CDP_PORT); -// Отладочный порт браузера живёт в конфиге моста: он один на все проекты, -// и держать его копию здесь значит однажды разойтись. -const bridgeDir = dirname(cfg.bridge); -const bridgeCfg = JSON.parse( - await readFile(join(bridgeDir, 'config.json'), 'utf8').catch(() => '{}')); -const cdpPort = Number(process.env.DEEPSEEK_CDP_PORT || bridgeCfg.port || 9333); +configure(cfg); -// Роли — это отдельные чаты DeepSeek под разные режимы (обычный, с поиском, -// со зрением). Режим выбирается при создании чата и потом не меняется, поэтому -// чатов несколько. Снаружи они выглядят как разные «модели» Ollama. -const ROLES = ['think', 'search', 'vision']; const MODELS = ROLES.map(r => 'deepseek:' + r); -// ------------------------------------------------------------------ браузер - -const cdp = path => fetch(`http://127.0.0.1:${cdpPort}${path}`, - { signal: AbortSignal.timeout(3000) }); - -async function browserAlive() { - try { - return (await cdp('/json/version')).ok; - } catch { return false; } -} - -const sleep = ms => new Promise(r => setTimeout(r, ms)); - -// Флаги не украшение. Замер в ollama_orchestro 11.08.2026: у неактивного окна -// Windows сообщает браузеру, что оно перекрыто, рисование прекращается, а -// DeepSeek рисует блоки кода лениво — ответ приезжает без кода (3136 символов -// вместо 30 817). Эти флаги запрещают браузеру засыпать. -const BROWSER_FLAGS = [ - '--remote-allow-origins=*', - '--start-maximized', - '--disable-backgrounding-occluded-windows', - '--disable-renderer-backgrounding', - '--disable-background-timer-throttling', - '--disable-features=CalculateNativeWinOcclusion', -]; - -// Поднять браузер, если он не отвечает, и открыть вкладку с чатом, если её нет. -// Профиль и путь к браузеру берём из конфига моста — там уже выполнен вход -// в DeepSeek, а второй профиль означал бы второй логин. -// -// Браузер запускается напрямую, а не через browser-launch.ps1 из ollama_orchestro: -// тот скрипт рассчитан на человека за консолью — на занятом профиле он -// спрашивает Read-Host, а у сервиса некому отвечать. -async function ensureBrowser() { - if (!(await browserAlive())) { - if (!bridgeCfg.profileDir || !bridgeCfg.browserExe) { - throw new Error( - `Браузер не отвечает на порту ${cdpPort}, и запустить его сам не могу: ` + - `в ${join(bridgeDir, 'config.json')} не задан profileDir или browserExe.`); - } - console.log('Браузер не отвечает, поднимаю...'); - spawn(bridgeCfg.browserExe, - [`--user-data-dir=${bridgeCfg.profileDir}`, - `--remote-debugging-port=${cdpPort}`, - ...BROWSER_FLAGS, cfg.chatUrl], - { detached: true, stdio: 'ignore' }).unref(); - // Холодный профиль поднимается неспешно; 60 секунд с запасом. - for (let i = 0; i < 30 && !(await browserAlive()); i++) await sleep(2000); - if (!(await browserAlive())) { - throw new Error( - `Браузер так и не открыл отладочный порт ${cdpPort}. Скорее всего этот ` + - `профиль уже открыт БЕЗ отладочного порта: новый процесс отдаёт ссылку ` + - `старому окну и завершается. Закройте окно с профилем ` + - `${bridgeCfg.profileDir} и повторите.`); - } - } - - const list = await (await cdp('/json/list')).json().catch(() => []); - if (list.some(t => String(t.url || '').includes('deepseek'))) return; - // Новая вкладка в свежих сборках Chromium открывается ТОЛЬКО методом PUT: - // на GET протокол отвечает 405 и молча ничего не делает. - const r = await fetch( - `http://127.0.0.1:${cdpPort}/json/new?url=${encodeURIComponent(cfg.chatUrl)}`, - { method: 'PUT' }); - if (!r.ok) throw new Error(`Не удалось открыть вкладку DeepSeek: ${r.status}.`); - await sleep(4000); // дать чату прогрузиться до первого вопроса -} - -// ------------------------------------------------------------------ мост - -// Вопрос и ответ ездят файлами, а не через stdout: вопрос бывает на десяток -// килобайт, ответ — на сотню, и труба тут только мешает. -async function askBridge(prompt, role, imageBytes) { - await ensureBrowser(); - const dir = await mkdtemp(join(tmpdir(), 'dsapi-')); - const qFile = join(dir, 'q.txt'), aFile = join(dir, 'a.txt'); - const iFile = join(dir, 'image.jpg'); - try { - await writeFile(qFile, prompt, 'utf8'); - const args = ['ask', '--file', qFile, '--out', aFile, '--quiet', '--no-seed', - '--chat', role, '--timeout', String(cfg.timeout)]; - if (imageBytes) { - await writeFile(iFile, imageBytes); - args.push('--image', iFile); - } - await new Promise((resolve, reject) => { - const ch = spawn(process.execPath, [cfg.bridge, ...args], - { cwd: bridgeDir, stdio: ['ignore', 'ignore', 'pipe'] }); - let err = ''; - ch.stderr.on('data', d => { err += d; }); - ch.on('error', reject); - ch.on('close', c => (c === 0 ? resolve() : reject(new Error( - `мост вышел с кодом ${c}. ${err.trim().slice(0, 300)}`)))); - }); - return await readFile(aFile, 'utf8'); - } finally { - await rm(dir, { recursive: true, force: true }).catch(() => {}); - } -} - // Вкладка одна, и два запроса разом затрут друг другу поле ввода. Поэтому // очередь: запросы идут строго по одному. // ponytail: очередь без ограничения длины — если клиент завалит сервис @@ -221,7 +118,7 @@ const server = createServer(async (req, res) => { if (!(await browserAlive())) { return json(res, 503, { models: [], - error: `Браузер с отладочным портом ${cdpPort} не отвечает. ` + + error: `Браузер с отладочным портом ${cfg.cdpPort} не отвечает. ` + 'Он поднимется сам при первом запросе к /api/chat; если этого ' + 'не произошло — профиль, скорее всего, уже открыт без ' + 'отладочного порта, закройте то окно.', @@ -239,15 +136,23 @@ const server = createServer(async (req, res) => { return json(res, 200, { version: 'deepseek-api' }); } + // Своё, сверх протокола Ollama: что видно во вкладке прямо сейчас. + if (req.method === 'GET' && url.pathname === '/api/probe') { + return json(res, 200, await serial(() => probe())); + } + if (req.method === 'POST' && url.pathname === '/api/chat') { const body = await readBody(req); const prompt = flatten(body.messages); if (!prompt) return json(res, 400, { error: 'пустой запрос' }); - const image = imageOf(body.messages); - const role = image ? 'vision' : roleOf(body.model); + const imageBytes = imageOf(body.messages); + const role = imageBytes ? 'vision' : roleOf(body.model); const started = Date.now(); - console.log(`[${role}] ${prompt.length} симв.${image ? ' + картинка' : ''}`); - const answer = await serial(() => askBridge(prompt, role, image)); + console.log(`[${role}] ${prompt.length} симв.${imageBytes ? ' + картинка' : ''}`); + const answer = await serial(() => ask({ + prompt, role, imageBytes, timeout: cfg.timeout, + log: m => console.log(` ${m}`), + })); console.log(`[${role}] ответ ${answer.length} симв. за ${ Math.round((Date.now() - started) / 1000)} с`); const model = body.model || ('deepseek:' + role); @@ -268,7 +173,9 @@ const server = createServer(async (req, res) => { const prompt = [body.system, body.prompt].filter(Boolean).join('\n\n').trim(); if (!prompt) return json(res, 400, { error: 'пустой запрос' }); const role = roleOf(body.model); - const answer = await serial(() => askBridge(prompt, role, null)); + const answer = await serial(() => ask({ + prompt, role, timeout: cfg.timeout, log: m => console.log(` ${m}`), + })); const head = { model: body.model || ('deepseek:' + role), created_at: now() }; if (body.stream === false) { return json(res, 200, { ...head, response: answer, done: true, done_reason: 'stop' }); @@ -296,14 +203,15 @@ const server = createServer(async (req, res) => { // По умолчанию слушаем все адреса: подключаются и с этой машины, и с соседней — // ровно как к самой Ollama. Сервис ходит в браузер под вашим логином, поэтому // в недоверенной сети host в config.json стоит сузить до 127.0.0.1. +// // Запускаемся только когда файл вызвали напрямую: перевод формата проверяется // из test.mjs импортом, и поднимать ради этого сервер незачем. if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { -server.listen(cfg.port, cfg.host, () => { - console.log(`DeepSeek прикидывается Ollama: http://localhost:${cfg.port}`); - if (cfg.host !== '0.0.0.0') console.log(`Слушаем только ${cfg.host}`); - console.log(`Модели: ${MODELS.join(', ')}`); - console.log(`Мост: ${cfg.bridge} (браузер на порту ${cdpPort})`); - console.log('Остановить — Ctrl+C'); -}); + server.listen(cfg.port, cfg.host, () => { + console.log(`DeepSeek прикидывается Ollama: http://localhost:${cfg.port}`); + if (cfg.host !== '0.0.0.0') console.log(`Слушаем только ${cfg.host}`); + console.log(`Модели: ${MODELS.join(', ')}`); + console.log(`Браузер: порт ${cfg.cdpPort}, профиль ${cfg.profileDir || '(не задан)'}`); + console.log('Остановить — Ctrl+C'); + }); }