Самодостаточность: движок браузера переехал внутрь, DeepShim больше не нужен

Раньше сервис был обёрткой: каждый запрос спавнил ollama_orchestro/ask.mjs
дочерним процессом, гонял вопрос и ответ через временные файлы и читал
чужой config.json ради профиля браузера. Работать это могло только рядом
с установленным DeepShim.

Теперь всё своё. browser.mjs — перенесённый из ask.mjs движок: CDP-сессия,
помощники страницы со всем, что удалось выяснить про разметку DeepSeek,
отправка с подтверждением, ожидание ответа по состоянию кнопки, работа
с чатами. Пульт, разбор патчей и git остались в DeepShim, сюда не поехали.

Побочно ушли дочерний процесс на каждый запрос и временные файлы: вопрос
и ответ теперь просто переменные. Файл на диске остался только для картинки —
<input type="file"> иначе не наполнить.

Свои настройки вместо чужих: 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 <noreply@anthropic.com>
This commit is contained in:
Alex Cube
2026-08-26 13:09:41 +03:00
co-authored by Claude Opus 5
parent f5cf061fcc
commit 8c52cf101c
6 changed files with 1307 additions and 296 deletions
+6
View File
@@ -1,3 +1,9 @@
node_modules/
*.log
# Рабочее состояние, а не настройка: id живых чатов. Файл заводится сам
# и переписывается при каждой смене чата. Образец — chats.example.json.
chats.json
# Свой на каждой машине: пути к браузеру и профилю.
config.local.json
+114 -161
View File
@@ -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 с | 812 с |
| короткий вопрос | 2–3 с | 712 с |
| запрос, на котором меняется чат | — | ~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
├─> поднять браузер, если он не отвечает
├─> 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, перенесено вместе
с комментариями — это знание дороже кода.
---
## Лицензия
+1128
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -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"
}
}
+3 -1
View File
@@ -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
+33 -125
View File
@@ -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,6 +203,7 @@ 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) {
@@ -303,7 +211,7 @@ 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(`Браузер: порт ${cfg.cdpPort}, профиль ${cfg.profileDir || '(не задан)'}`);
console.log('Остановить — Ctrl+C');
});
}