commit f5cf061fcc740406067658e0649658f85554deb3 Author: Alex Cube Date: Wed Aug 26 12:49:03 2026 +0300 DeepSeek, притворяющийся Ollama У веб-версии DeepSeek нет API: ни ключа, ни эндпойнта, только вкладка в браузере. Этот сервис держит такую вкладку и отдаёт наружу протокол Ollama, поэтому любая программа, которая уже умеет ходить в Ollama, подключается к DeepSeek сменой одного адреса. GET /api/tags три "модели" по числу режимов чата; 503, пока браузер с чатом не поднят GET /api/version POST /api/chat images в сообщении уводят в чат со зрением POST /api/generate POST /api/embeddings 501: веб-чат векторов не отдаёт Внутри — ollama_orchestro/ask.mjs дочерним процессом: он ничего не экспортирует и выполняется при импорте, это программа командной строки. Браузер поднимается напрямую из Node, а не через browser-launch.ps1 из DeepShim: тот рассчитан на человека за консолью и на занятом профиле спрашивает Read-Host, а у сервиса некому отвечать. Флаги окна оттуда перенесены: у неактивного окна DeepSeek не дорисовывает блоки кода. Чего нет: настоящего потока (формат соблюдается, но ответ приходит целиком), temperature и num_predict, эмбеддингов, параллельных запросов — вкладка одна, запросы идут очередью по одному. Сервис слушает все адреса и ходит в DeepSeek под вашим логином. Только доверенная сеть; сузить до этого компьютера — host в config.json. Проверка перевода формата без браузера: node test.mjs Co-Authored-By: Claude Opus 5 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..bfe94ec --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Батникам нужны CRLF: с LF cmd.exe способен повести себя странно — +# например, оборвать последнюю строку или неверно разобрать блок в скобках. +*.cmd text eol=crlf +*.bat text eol=crlf + +# Остальному текстовому — LF в репозитории, нормализация при выгрузке. +*.mjs text +*.md text +*.json text +.gitignore text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..962829c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*.log +config.local.json diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..50e8d7b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Alex + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..701a22a --- /dev/null +++ b/README.md @@ -0,0 +1,488 @@ +# deepseek-api + +**DeepSeek, притворяющийся Ollama.** + +У веб-версии DeepSeek нет API: ни ключа, ни эндпойнта — только вкладка +в браузере, куда человек руками пишет вопросы. Эта программа открывает такую +вкладку, пишет в неё за вас и отдаёт наружу обычный HTTP — тот самый протокол, +на котором говорит [Ollama](https://ollama.com). + +Смысл: **любая программа, которая уже умеет работать с Ollama, начинает +работать с DeepSeek после смены одного адреса.** Ни строчки кода в ней менять +не нужно. + +``` +ваша программа ──HTTP──> deepseek-api ──> браузер ──> chat.deepseek.com + (думает, что │ + говорит с Ollama) <──────────── ответ ───────────────┘ +``` + +Платить не нужно — используется ваш обычный аккаунт DeepSeek. Взамен всё +медленно (секунды, а не миллисекунды) и работает только пока открыт браузер. + +--- + +## Оглавление + +1. [Что понадобится](#что-понадобится) +2. [Установка по шагам](#установка-по-шагам) +3. [Проверка, что всё работает](#проверка-что-всё-работает) +4. [Как подключить свою программу](#как-подключить-свою-программу) +5. [Что умеет и чего не умеет](#что-умеет-и-чего-не-умеет) +6. [Настройка](#настройка) +7. [Если не работает](#если-не-работает) +8. [Безопасность](#безопасность) +9. [Как это устроено внутри](#как-это-устроено-внутри) + +--- + +## Что понадобится + +| Что | Зачем | Как проверить, что есть | +|---|---|---| +| **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, кнопка слева. После установки **закройте и откройте заново** +командную строку, иначе `node` в ней не найдётся. + +--- + +## Установка по шагам + +Всего шагов семь. Делайте по порядку, каждый заканчивается проверкой — +не переходите к следующему, пока предыдущий не дал ожидаемого результата. + +### Шаг 1. Скачать обе программы + +Нужны две: эта и DeepShim, в котором лежит код управления браузером. +Положите их **рядом, в одну папку**: + +``` +C:\#Projects\ + deepseek-api\ <- эта программа + ollama_orchestro\ <- DeepShim +``` + +```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 с тех же страниц и распаковать. + +> **Проверка:** в `C:\#Projects\deepseek-api\` лежит `server.mjs`, +> в `C:\#Projects\ollama_orchestro\` — `ask.mjs`. + +### Шаг 2. Завести отдельный профиль браузера + +Браузером будет управлять программа, поэтому работать в нём вам не нужно — +и не стоит: он будет самопроизвольно открываться и переключать вкладки. +Заведите под него **отдельный профиль** — это просто пустая папка, +браузер сам её заполнит: + +```cmd +mkdir C:\Browsers\deepseek +``` + +Имя любое, запомните путь — понадобится на следующем шаге. + +### Шаг 3. Настроить DeepShim + +В папке `ollama_orchestro` создайте файл `config.json`: + +```json +{ + "profileDir": "C:\\Browsers\\deepseek", + "browserExe": "C:\\Program Files\\Yandex\\YandexBrowser\\Application\\browser.exe", + "port": 9333 +} +``` + +Три значения: + +- **`profileDir`** — папка из шага 2. +- **`browserExe`** — путь к браузеру. Типичные: + + | Браузер | Путь | + |---|---| + | Яндекс.Браузер | `C:\Program Files\Yandex\YandexBrowser\Application\browser.exe` | + | 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 верный. Если ошибка — ищите пропущенную +> запятую или одинарный слэш. + +### Шаг 4. Запустить браузер и войти в DeepSeek + +```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 +``` + +> **Проверка:** в окне появилось +> ``` +> DeepSeek прикидывается Ollama: http://localhost:11500 +> Модели: deepseek:think, deepseek:search, deepseek:vision +> ``` + +Это окно должно оставаться открытым — пока оно живо, работает и сервис. +Останов — `Ctrl+C` или просто закрыть окно. + +**Готово.** Дальше — проверка. + +--- + +## Проверка, что всё работает + +Откройте **вторую** командную строку (первая занята сервисом) и спросите +что-нибудь: + +```cmd +curl http://localhost:11500/api/tags +``` + +Ожидается список из трёх «моделей»: + +```json +{"models":[{"name":"deepseek:think",…},{"name":"deepseek:search",…},{"name":"deepseek:vision",…}]} +``` + +Теперь настоящий вопрос: + +```cmd +curl http://localhost:11500/api/chat -d "{\"model\":\"deepseek:think\",\"stream\":false,\"messages\":[{\"role\":\"user\",\"content\":\"Ответь одним словом: столица Японии?\"}]}" +``` + +Через несколько секунд: + +```json +{"model":"deepseek:think","created_at":"…","message":{"role":"assistant","content":"Токио"},"done":true,"done_reason":"stop"} +``` + +Если пришло — всё работает. В окне сервиса при этом видно, сколько заняло: + +``` +[think] 37 симв. +[think] ответ 5 симв. за 8 с +``` + +--- + +## Как подключить свою программу + +Везде, где программа спрашивает **адрес Ollama**, укажите: + +``` +http://localhost:11500 +``` + +Моделью выберите `deepseek:think`. + +Если программа работает на **другом компьютере** в вашей сети — вместо +`localhost` укажите адрес этого компьютера, например `http://192.168.0.10:11500`. +Сервис слушает все адреса, ничего дополнительно настраивать не нужно. + +### Три «модели» — это три режима + +| Модель | Что это | Когда брать | +|---|---|---| +| `deepseek:think` | обычный режим (expert) | почти всегда | +| `deepseek:search` | с поиском в интернете | нужны свежие данные | +| `deepseek:vision` | со зрением | разбор картинок | + +Картинку можно просто приложить к запросу, как это делает Ollama — +в поле `images` массивом base64. Тогда режим `vision` включится сам, +независимо от того, какую модель вы указали. + +**Незнакомое имя модели — не ошибка.** Если программа настойчиво шлёт свою +`llama3` или `qwen3:8b`, запрос уйдёт в обычный чат. Это сделано нарочно: +чужую программу можно переключить на DeepSeek, не разыскивая в её настройках +поле с моделью. + +### Пример: помощник hlamingo + +В [hlamingo](https://git.08h.ru/alex/hlamingo) на вкладке **⚙** есть выбор +«Кто думает» — переключите на «DeepSeek», укажите адрес, сохраните. + +--- + +## Что умеет и чего не умеет + +### Эндпойнты + +| Адрес | Что делает | +|---|---| +| `GET /api/tags` | список «моделей». **Отвечает 503, пока браузер не поднят** — по этому клиенты понимают, что сервис не готов | +| `GET /api/version` | опознавательная строка | +| `POST /api/chat` | сообщения → ответ | +| `POST /api/generate` | то же, но вопрос одной строкой | +| `POST /api/embeddings` | **501** — веб-чат векторов не отдаёт | + +### Чего нет и не будет + +- **Настоящего потока.** Ollama печатает ответ по словам; здесь он приходит + целиком. Формат потока при этом соблюдается (клиент получит две строки JSON + вместо многих), поэтому программы, которые ждут поток, не ломаются — + просто ответ появляется разом. Хотите один объект вместо потока — присылайте + `"stream": false`. +- **`temperature`, `num_predict` и прочих настроек генерации.** Веб-чат их + не принимает. Присланные — молча игнорируются. +- **Эмбеддингов** (векторов для поиска по смыслу). Веб-чат их не считает. + Вместо пустого вектора возвращается честная ошибка 501: молчаливая подделка + испортила бы поиск так, что вы бы этого не заметили. Если вашей программе + нужен и чат, и поиск по смыслу — оставьте поиск на настоящей Ollama. +- **Параллельных запросов.** Вкладка одна, и два запроса разом затрут друг + другу поле ввода. Запросы встают в очередь и идут по одному. + +### Сколько ждать + +Порядок величин, замерено на разборе фразы и генерации тегов: + +| | Ollama (qwen3:8b, локально) | DeepSeek (веб) | +|---|---|---| +| короткий вопрос | 2–3 с | 8–12 с | +| первый запрос после запуска браузера | — | +15 с на подъём окна | + +Если ваша программа считает, что модель обязана ответить за пару секунд, — +увеличьте в ней таймаут. + +--- + +## Настройка + +### `config.json` + +| Ключ | По умолчанию | Смысл | +|---|---|---| +| `port` | `11500` | порт, на котором сервис слушает | +| `host` | `0.0.0.0` | какие адреса слушать; `127.0.0.1` — только этот компьютер | +| `bridge` | `C:/#Projects/ollama_orchestro/ask.mjs` | путь к DeepShim | +| `chatUrl` | `https://chat.deepseek.com/` | что открывать во вкладке | +| `defaultRole` | `think` | режим для незнакомых имён моделей | +| `timeout` | `300` | сколько секунд ждать **бездействия** страницы | + +Про `timeout`: это не «сколько ждать ответа». Пока DeepSeek печатает, ждём +сколько угодно; срок отсчитывается от последнего признака жизни страницы. + +### Переменные окружения + +Перебивают файл, удобны для разовых запусков: + +```cmd +set DEEPSEEK_API_PORT=11501 +set DEEPSEEK_CDP_PORT=9444 +start.cmd +``` + +Профиль браузера, путь к нему и порт управления берутся из +`ollama_orchestro/config.json` — там уже выполнен вход в DeepSeek, и второй +экземпляр этих настроек однажды разошёлся бы с первым. + +--- + +## Если не работает + +Сначала посмотрите в окно сервиса — там пишется, что происходит. + +| Что видно | В чём дело | Что делать | +|---|---|---| +| `Браузер с отладочным портом 9333 не отвечает` в ответе на `/api/tags` | браузер закрыт | Ничего: он поднимется сам при первом запросе к `/api/chat`. Если не поднялся — следующая строка | +| `Браузер так и не открыл отладочный порт` | этот профиль уже открыт **без** порта управления: новый процесс отдаёт ссылку старому окну и молча завершается | Закройте **все** окна браузера с этим профилем и повторите. Проверить, что не осталось: диспетчер задач, процессы браузера | +| `в config.json не задан profileDir или browserExe` | шаг 3 пропущен или файл лежит не в той папке | `ollama_orchestro\config.json`, оба ключа заполнены | +| `мост вышел с кодом 1. Вкладка chat.deepseek.com не открыта` | вкладку закрыли руками | Откройте `chat.deepseek.com` в том же окне браузера, или перезапустите его через `start-browser.cmd` | +| Ответ приходит **обрезанным**, без кода | окно браузера свёрнуто — Windows перестаёт его отрисовывать, а DeepSeek дорисовывает блоки кода лениво | Разверните окно. Не сворачивайте: уводите на другой рабочий стол или за другие окна | +| Ответ приходит **чужой**, не на ваш вопрос | вкладка не успела обновиться | Повторите запрос. Если повторяется — перезапустите браузер | +| В окне мелькнуло `правки в этом чате кончились, завожу новый` | исчерпан лимит в шесть правок одного сообщения | Ничего. Это штатная смена чата, см. [Чаты изнашиваются](#чаты-изнашиваются-и-меняются-сами) | +| `EADDRINUSE: address already in use` | сервис уже запущен в другом окне | Закройте то окно либо возьмите другой порт (`DEEPSEEK_API_PORT`) | +| `node не является внутренней или внешней командой` | Node не установлен или командная строка открыта до его установки | Поставьте Node, **закройте и откройте заново** командную строку | +| Просит войти в DeepSeek при каждом запуске | профиль браузера каждый раз новый | Проверьте, что `profileDir` указывает на одну и ту же существующую папку | + +Отдельная диагностика самого моста — из папки DeepShim: + +```cmd +node ask.mjs probe +``` + +Она скажет, видна ли вкладка, найдено ли поле ввода и не просит ли сайт +залогиниться. + +Проверить перевод формата, не поднимая браузер: + +```cmd +cd C:\#Projects\deepseek-api +node test.mjs +``` + +--- + +## Безопасность + +Прочтите, это важнее, чем кажется. + +- **Сервис слушает все сетевые адреса** (`0.0.0.0`), а не только этот + компьютер. Так задумано: подключаться должны и соседние машины, ровно как + к самой Ollama. **Аутентификации нет.** +- **Он ходит в DeepSeek под вашим логином.** Любой, кто достучится до порта + 11500, задаёт вопросы от вашего имени и расходует ваши лимиты. +- Поэтому: только домашняя или доверенная сеть. **Не пробрасывайте порт + наружу** и не открывайте его на публичном Wi-Fi. +- Всё, что вы отправляете, уходит на серверы DeepSeek — как если бы вы + написали это в чат руками. Для приватных данных берите локальную Ollama. + +Если сервис нужен только на этом компьютере, впишите в `config.json`: + +```json +"host": "127.0.0.1" +``` + +Тогда снаружи к нему не подключиться вовсе. + +--- + +## Как это устроено внутри + +``` +POST /api/chat + │ + ├─ склеить system + user в один текст (у веб-чата нет ролей) + ├─ выбрать чат по имени модели (think / search / vision) + ├─ встать в очередь (вкладка одна) + │ + ├─> написать вопрос во временный файл + ├─> node ask.mjs ask --file … --out … (дочерний процесс) + │ └─> Chrome DevTools Protocol + │ └─> правка ПЕРВОГО сообщения чата + │ └─> дождаться, пока DeepSeek допечатает + ├─< прочитать ответ из файла + │ + └─ отдать в формате Ollama +``` + +Почему правка первого сообщения, а не новое: DeepSeek отбрасывает всё, +что было после отредактированного сообщения, и чат остаётся из одного обмена. +Контекст не растёт, мусор между запросами не копится, каждый вопрос — +с чистого листа. + +Почему дочерний процесс, а не импорт: `ask.mjs` — программа командной строки, +она ничего не экспортирует и выполняется сразу при подключении. Переделывать +чужой проект ради этого не стали. + +Почему браузер поднимается напрямую, а не через `browser-launch.ps1` +из DeepShim: тот скрипт рассчитан на человека за консолью — на занятом профиле +он задаёт вопрос и ждёт ответа, а у сервиса некому отвечать. + +### Чаты изнашиваются и меняются сами + +**Одно сообщение DeepSeek разрешает править ровно шесть раз.** После шестой +правки кнопка перестаёт открывать поле ввода — и чат становится непригоден. + +Ничего делать не нужно, это уже предусмотрено. На седьмом вопросе DeepShim +заводит новый чат того же режима, задаёт вопрос сразу в нём, записывает новый +номер в `chats.json` и **удаляет отработавший чат**. Снаружи не видно ничего: +тот же запрос, тот же ответ, разве что на несколько секунд дольше. + +Отсюда два следствия: + +- **`chats.json` — не настройка, а переменная.** Номера в нём меняются сами + по мере работы. Не правьте его руками и не удивляйтесь, если git покажет + в нём изменения. +- **В сайдбаре DeepSeek не копится мусор.** Старые чаты удаляются, плюс перед + каждым вопросом сайдбар подчищается от посторонних служебных чатов. + +Если вы удалите рабочий чат вручную — тоже ничего страшного: следующий запрос +заметит пропажу и создаст новый. + +Файлы: + +| Файл | Что в нём | +|---|---| +| `server.mjs` | всё: HTTP, очередь, запуск браузера, вызов моста | +| `config.json` | настройки этой машины | +| `start.cmd` | запуск | +| `test.mjs` | проверка перевода формата, браузер не нужен | + +Зависимостей нет — только то, что есть в Node 22. + +--- + +## Лицензия + +MIT. diff --git a/config.json b/config.json new file mode 100644 index 0000000..eef8004 --- /dev/null +++ b/config.json @@ -0,0 +1,8 @@ +{ + "port": 11500, + "host": "0.0.0.0", + "bridge": "C:/#Projects/ollama_orchestro/ask.mjs", + "chatUrl": "https://chat.deepseek.com/", + "defaultRole": "think", + "timeout": 300 +} diff --git a/server.mjs b/server.mjs new file mode 100644 index 0000000..615ae12 --- /dev/null +++ b/server.mjs @@ -0,0 +1,309 @@ +#!/usr/bin/env node +// DeepSeek, притворяющийся Ollama. +// +// Веб-чат DeepSeek — не API: у него нет ни ключа, ни эндпойнта, зато есть +// вкладка в браузере, куда можно писать. Этот сервис поднимает такую вкладку +// и отдаёт наружу протокол Ollama: /api/tags и /api/chat. Любая программа, +// которая уже умеет ходить в Ollama, подключается сюда сменой одного адреса. +// +// Что внутри происходит: запрос → файл → мост ollama_orchestro/ask.mjs → +// правка первого сообщения в чате DeepSeek → ответ → файл → HTTP. +// +// Зависимостей нет: Node 22+ со встроенным fetch. + +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 { join, dirname } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +// 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', + chatUrl: 'https://chat.deepseek.com/', + defaultRole: 'think', + timeout: 300, + ...JSON.parse(await readFile(join(HERE, 'config.json'), 'utf8').catch(() => '{}')), +}; +if (process.env.DEEPSEEK_API_PORT) cfg.port = Number(process.env.DEEPSEEK_API_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); + +// Роли — это отдельные чаты 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: очередь без ограничения длины — если клиент завалит сервис +// запросами, они просто будут ждать. Появится нужда — резать по длине очереди. +let chain = Promise.resolve(); +function serial(fn) { + const next = chain.then(fn, fn); + chain = next.catch(() => {}); + return next; +} + +// ------------------------------------------------------------------ Ollama + +// Роль из имени модели: "deepseek:search" → "search". Незнакомое имя (клиент +// прислал свою старую модель вроде qwen3:8b) — не повод падать: берём обычный +// чат, иначе смена движка в чужой программе требовала бы ещё и правки модели. +export function roleOf(model) { + const tail = String(model || '').split(':').pop().toLowerCase(); + return ROLES.includes(tail) ? tail : cfg.defaultRole; +} + +// Ollama принимает роли system/user/assistant отдельными сообщениями, у чата +// DeepSeek такого разделения нет — склеиваем в один текст. Роль assistant тоже +// сохраняем: в редких сценариях клиент подкладывает начало ответа. +export function flatten(messages) { + return (messages || []) + .map(m => String(m?.content || '').trim()) + .filter(Boolean) + .join('\n\n'); +} + +// Картинка приезжает в base64 внутри сообщения — так её кладёт Ollama. +export function imageOf(messages) { + for (const m of messages || []) { + const img = (m?.images || [])[0]; + if (img) return Buffer.from(String(img), 'base64'); + } + return null; +} + +const json = (res, code, body) => { + const text = JSON.stringify(body); + res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(text) }); + res.end(text); +}; + +async function readBody(req) { + const chunks = []; + for await (const c of req) chunks.push(c); + return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); +} + +const now = () => new Date().toISOString(); + +// Клиенты Ollama по умолчанию просят поток и ждут не один объект, а несколько +// строк JSON подряд. Настоящего потока у нас нет — веб-чат отдаёт ответ целиком, +// — но формат соблюсти обязаны, иначе клиент не разберёт ответ вообще. +// Поэтому поток из двух строк: весь текст, затем признак конца. +function stream(res, first, last) { + res.writeHead(200, { 'Content-Type': 'application/x-ndjson; charset=utf-8' }); + res.write(JSON.stringify(first) + '\n'); + res.end(JSON.stringify(last) + '\n'); +} + +const server = createServer(async (req, res) => { + const url = new URL(req.url, 'http://x'); + try { + // Проверка связи. Клиенты Ollama дёргают /api/tags, чтобы понять, жив ли + // сервер, — и здесь это должно означать «браузер на месте». Иначе клиент + // сочтёт нас живыми и повиснет на первом же запросе на все пять минут. + if (req.method === 'GET' && (url.pathname === '/api/tags' || url.pathname === '/')) { + if (!(await browserAlive())) { + return json(res, 503, { + models: [], + error: `Браузер с отладочным портом ${cdpPort} не отвечает. ` + + 'Он поднимется сам при первом запросе к /api/chat; если этого ' + + 'не произошло — профиль, скорее всего, уже открыт без ' + + 'отладочного порта, закройте то окно.', + }); + } + return json(res, 200, { + models: MODELS.map(m => ({ + name: m, model: m, modified_at: now(), size: 0, + details: { family: 'deepseek', parameter_size: 'web', quantization_level: '-' }, + })), + }); + } + + if (req.method === 'GET' && url.pathname === '/api/version') { + return json(res, 200, { version: 'deepseek-api' }); + } + + 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 started = Date.now(); + console.log(`[${role}] ${prompt.length} симв.${image ? ' + картинка' : ''}`); + const answer = await serial(() => askBridge(prompt, role, image)); + console.log(`[${role}] ответ ${answer.length} симв. за ${ + Math.round((Date.now() - started) / 1000)} с`); + const model = body.model || ('deepseek:' + role); + const head = { model, created_at: now() }; + if (body.stream === false) { + return json(res, 200, { ...head, message: { role: 'assistant', content: answer }, + done: true, done_reason: 'stop' }); + } + return stream(res, + { ...head, message: { role: 'assistant', content: answer }, done: false }, + { ...head, message: { role: 'assistant', content: '' }, + done: true, done_reason: 'stop' }); + } + + // /api/generate — тот же чат, только промпт строкой. + if (req.method === 'POST' && url.pathname === '/api/generate') { + const body = await readBody(req); + 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 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' }); + } + return stream(res, { ...head, response: answer, done: false }, + { ...head, response: '', done: true, done_reason: 'stop' }); + } + + // Эмбеддинги веб-чат не отдаёт в принципе. Честный отказ лучше пустого + // вектора: клиент увидит причину, а не молча испорченный поиск. + if (url.pathname === '/api/embeddings' || url.pathname === '/api/embed') { + return json(res, 501, { + error: 'DeepSeek через веб-чат не умеет эмбеддинги. ' + + 'Оставьте поиск по смыслу на настоящей Ollama.', + }); + } + + json(res, 404, { error: 'нет такого эндпойнта' }); + } catch (e) { + console.error('Ошибка:', e.message); + json(res, 500, { error: String(e && e.message || e) }); + } +}); + +// По умолчанию слушаем все адреса: подключаются и с этой машины, и с соседней — +// ровно как к самой 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'); +}); +} diff --git a/start.cmd b/start.cmd new file mode 100644 index 0000000..64edf62 --- /dev/null +++ b/start.cmd @@ -0,0 +1,6 @@ +@echo off +rem ASCII only - cmd.exe reads .cmd in the system codepage, not UTF-8. +rem DeepSeek web chat pretending to be Ollama. Stop with Ctrl+C. +title deepseek-api +node "%~dp0server.mjs" +if errorlevel 1 pause diff --git a/test.mjs b/test.mjs new file mode 100644 index 0000000..03caee6 --- /dev/null +++ b/test.mjs @@ -0,0 +1,29 @@ +// Проверка перевода формата Ollama в один вопрос для веб-чата. +// Браузер и сам DeepSeek тут не нужны: node test.mjs +import assert from 'node:assert/strict'; +import { roleOf, flatten, imageOf } from './server.mjs'; + +// Имя модели → чат DeepSeek. Незнакомое имя не ошибка: чужую программу можно +// переключить на DeepSeek, не трогая в ней настройку модели. +assert.equal(roleOf('deepseek:search'), 'search'); +assert.equal(roleOf('deepseek:VISION'), 'vision'); +assert.equal(roleOf('qwen3:8b'), 'think'); +assert.equal(roleOf(undefined), 'think'); + +// У Ollama роли — отдельные сообщения, у веб-чата их нет: склеиваем по порядку, +// пустые выбрасываем. Потеря system означала бы потерю всей инструкции. +assert.equal( + flatten([{ role: 'system', content: 'Отвечай JSON' }, + { role: 'user', content: ' где кусачки? ' }, + { role: 'user', content: '' }]), + 'Отвечай JSON\n\nгде кусачки?'); +assert.equal(flatten([]), ''); +assert.equal(flatten(undefined), ''); + +// Картинка приезжает в base64 внутри сообщения — так её кладёт Ollama. +const bytes = Buffer.from([0xff, 0xd8, 0xff]); +assert.deepEqual(imageOf([{ role: 'user', content: 'что это?', + images: [bytes.toString('base64')] }]), bytes); +assert.equal(imageOf([{ role: 'user', content: 'без картинки' }]), null); + +console.log('OK: перевод формата работает');