Одна модель вместо трёх: DeepSeek убрал выбор из веб-морды

DeepSeek больше не даёт выбирать модель: radiogroup Instant / Expert /
Vision (div[data-model-type][role="radio"]) со страницы исчез, оставшаяся
модель универсальна и разбирает в том числе картинки. Вместе с выбором
отпали и три роли — think, search, vision, — а с ними отдельный чат под
каждую: теперь чат один.

Заодно выяснилось, что тумблеры DeepThink и Search больше не пропадают
после первого сообщения — они видны и переключаются в любом чате, в том
числе начатом. На их исчезновении держалась вся прежняя схема с чатами
под роль, так что нужда в ней отпала и по этой причине тоже.

DeepThink выключается перед каждым вопросом: он только показывает
размышления модели и раздувает ответ. Search не трогаем намеренно — это
тумблер человека: как поставлен во вкладке, так мост и спрашивает.

Попутно два бага, которые вылезли на живом прогоне:

- data-virtual-list-item-key у вопросов теперь отрицательные (-4, 2, -11,
  8 для двух обменов подряд), и __firstMsg сортировкой по возрастанию
  отдавал последний вопрос вместо первого. Правка уходила в чужое
  сообщение. Берём порядок DOM — список рисуется сверху вниз.

- Вопрос с картинкой уходил новым сообщением в рабочий чат и ломал
  уговор «в чате ровно один обмен», на котором держится правка первого
  сообщения. После такого чат оставался из двух обменов, и наружу
  отдавался ответ на предыдущий вопрос. Теперь картинка всегда открывает
  новый чат, а старый удаляется как обычно.

Снаружи: /api/tags отдаёт одну модель deepseek:chat, имя присланной
клиентом модели не смотрится вовсе, ключ defaultRole из config.json убран,
chats.json стал {"id": "..."} вместо карты ролей. /api/probe показывает
положение тумблеров.

Проверено живым прогоном: обычный вопрос, вопрос с картинкой, снова
обычный — ответы верные, чат ротируется, сайдбар чистится.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ez8B2cR3mbm1fjmAMa8JXS
This commit is contained in:
Alex Cube
2026-09-11 10:26:03 +03:00
co-authored by Claude Opus 5
parent adbcedb485
commit 6ccb5dd46b
6 changed files with 162 additions and 337 deletions
+42 -36
View File
@@ -93,7 +93,7 @@ mkdir C:\Browsers\deepseek
> окна мешают сервису. > окна мешают сервису.
> >
> **А вот чаты — это про аккаунт, не про профиль.** Перед каждым вопросом > **А вот чаты — это про аккаунт, не про профиль.** Перед каждым вопросом
> программа удаляет из сайдбара всё, чего нет в её `chats.json`. Чаты живут > программа удаляет из сайдбара всё, кроме своего рабочего чата. Чаты живут
> на сервере DeepSeek и привязаны к аккаунту, поэтому другой профиль браузера > на сервере DeepSeek и привязаны к аккаунту, поэтому другой профиль браузера
> с тем же логином не спасёт: две такие программы на одном аккаунте удаляют > с тем же логином не спасёт: две такие программы на одном аккаунте удаляют
> чаты друг друга, а ваша личная переписка в DeepSeek на этом аккаунте > чаты друг друга, а ваша личная переписка в DeepSeek на этом аккаунте
@@ -140,7 +140,7 @@ start.cmd
> **Проверка:** в окне появилось > **Проверка:** в окне появилось
> ``` > ```
> DeepSeek прикидывается Ollama: http://localhost:11500 > DeepSeek прикидывается Ollama: http://localhost:11500
> Модели: deepseek:think, deepseek:search, deepseek:vision > Модель: deepseek:chat
> ``` > ```
Это окно должно оставаться открытым — пока оно живо, работает и сервис. Это окно должно оставаться открытым — пока оно живо, работает и сервис.
@@ -167,10 +167,10 @@ curl http://localhost:11500/api/tags
его на другой рабочий стол или оставить позади других окон. его на другой рабочий стол или оставить позади других окон.
> **Проверка:** повторите `curl http://localhost:11500/api/tags` — теперь > **Проверка:** повторите `curl http://localhost:11500/api/tags` — теперь
> должен прийти список из трёх «моделей». > должна прийти одна «модель».
**Готово.** Чаты под каждый режим программа заведёт сама при первом же **Готово.** Рабочий чат программа заведёт сама при первом же вопросе —
вопросе — создавать их руками не нужно. создавать его руками не нужно.
--- ---
@@ -218,23 +218,23 @@ deepseek-api.exe
Откройте **вторую** командную строку (первая занята сервисом): Откройте **вторую** командную строку (первая занята сервисом):
```cmd ```cmd
curl http://localhost:11500/api/chat -d "{\"model\":\"deepseek:think\",\"stream\":false,\"messages\":[{\"role\":\"user\",\"content\":\"Ответь одним словом: столица Японии?\"}]}" curl http://localhost:11500/api/chat -d "{\"model\":\"deepseek:chat\",\"stream\":false,\"messages\":[{\"role\":\"user\",\"content\":\"Ответь одним словом: столица Японии?\"}]}"
``` ```
Через несколько секунд: Через несколько секунд:
```json ```json
{"model":"deepseek:think","created_at":"…","message":{"role":"assistant","content":"Токио"},"done":true,"done_reason":"stop"} {"model":"deepseek:chat","created_at":"…","message":{"role":"assistant","content":"Токио"},"done":true,"done_reason":"stop"}
``` ```
В окне сервиса при этом видно, что происходило: В окне сервиса при этом видно, что происходило:
``` ```
[think] 37 симв. вопрос 37 симв.
чат think не найден, создаю сразу с вопросом рабочий чат не найден, создаю сразу с вопросом
открыт новый чат открыт новый чат
создан новый чат think: 660347d5-… создан новый чат: 660347d5-…
[think] ответ 5 симв. за 9 с ответ 5 симв. за 9 с
``` ```
Отдельная диагностика — что сейчас видно во вкладке: Отдельная диагностика — что сейчас видно во вкладке:
@@ -243,8 +243,8 @@ curl http://localhost:11500/api/chat -d "{\"model\":\"deepseek:think\",\"stream\
curl http://localhost:11500/api/probe curl http://localhost:11500/api/probe
``` ```
Покажет адрес вкладки, найдено ли поле ввода, идёт ли генерация, список чатов Покажет адрес вкладки, найдено ли поле ввода, идёт ли генерация, список чатов,
и не просит ли сайт залогиниться. положение тумблеров DeepThink и Search и не просит ли сайт залогиниться.
--- ---
@@ -256,29 +256,36 @@ curl http://localhost:11500/api/probe
http://localhost:11500 http://localhost:11500
``` ```
Моделью выберите `deepseek:think`. Моделью выберите `deepseek:chat` — или оставьте, что стоит: имя модели
программа не смотрит вовсе.
Если программа работает на **другом компьютере** в вашей сети — вместо Если программа работает на **другом компьютере** в вашей сети — вместо
`localhost` укажите адрес этого компьютера, например `localhost` укажите адрес этого компьютера, например
`http://192.168.0.10:11500`. Сервис слушает все адреса, ничего дополнительно `http://192.168.0.10:11500`. Сервис слушает все адреса, ничего дополнительно
настраивать не нужно. настраивать не нужно.
### Три «модели» — это три режима ### Модель одна
| Модель | Что это | Когда брать | Раньше их было три (`think`, `search`, `vision`) — по числу моделей, между
|---|---|---| которыми давала выбирать веб-морда DeepSeek. **Выбора больше нет:** в сентябре
| `deepseek:think` | обычный режим (expert) | почти всегда | 2026 DeepSeek убрал переключатель Instant / Expert / Vision, оставшаяся модель
| `deepseek:search` | с поиском в интернете | нужны свежие данные | универсальна и разбирает в том числе картинки. Вслед за этим ушли и три режима.
| `deepseek:vision` | со зрением | разбор картинок |
Картинку можно приложить к запросу так же, как это делает Ollama — в поле Осталось одно имя — `deepseek:chat`. **Имя модели вообще не смотрится:**
`images` массивом base64. Тогда режим `vision` включится сам, независимо пришлёт программа свою `llama3` или `qwen3:8b` — запрос всё равно уйдёт
от того, какую модель вы указали. в DeepSeek. Это сделано нарочно: чужую программу можно переключить, не
разыскивая в её настройках поле с моделью. В ответе имя возвращается то же,
которое прислали, — иначе клиент решит, что ответила не та модель.
**Незнакомое имя модели — не ошибка.** Если программа настойчиво шлёт свою Картинку прикладывайте так же, как это делает Ollama — в поле `images`
`llama3` или `qwen3:8b`, запрос уйдёт в обычный чат. Это сделано нарочно: массивом base64.
чужую программу можно переключить на DeepSeek, не разыскивая в её настройках
поле с моделью. **Тумблер Search остаётся вашим.** Рядом с полем ввода у DeepSeek два
тумблера: DeepThink и Search. DeepThink программа выключает перед каждым
вопросом — он только показывает размышления модели и раздувает ответ.
А Search не трогает: как поставите его во вкладке руками, так и будет
работать. Хотите ответы со свежими данными из интернета — включите его
в окне браузера один раз.
### Пример: помощник hlamingo ### Пример: помощник hlamingo
@@ -293,7 +300,7 @@ http://localhost:11500
| Адрес | Что делает | | Адрес | Что делает |
|---|---| |---|---|
| `GET /api/tags` | список «моделей». **Отвечает 503, пока браузер не поднят** — по этому клиенты понимают, что сервис не готов | | `GET /api/tags` | список «моделей» — в нём одна. **Отвечает 503, пока браузер не поднят** — по этому клиенты понимают, что сервис не готов |
| `GET /api/version` | опознавательная строка | | `GET /api/version` | опознавательная строка |
| `GET /api/probe` | сверх протокола Ollama: что видно во вкладке прямо сейчас | | `GET /api/probe` | сверх протокола Ollama: что видно во вкладке прямо сейчас |
| `POST /api/chat` | сообщения → ответ | | `POST /api/chat` | сообщения → ответ |
@@ -343,7 +350,6 @@ http://localhost:11500
| `browserExe` | Яндекс.Браузер | путь к браузеру | | `browserExe` | Яндекс.Браузер | путь к браузеру |
| `profileDir` | `C:\Browsers\deepseek` | папка профиля браузера | | `profileDir` | `C:\Browsers\deepseek` | папка профиля браузера |
| `chatUrl` | `https://chat.deepseek.com/` | что открывать во вкладке | | `chatUrl` | `https://chat.deepseek.com/` | что открывать во вкладке |
| `defaultRole` | `think` | режим для незнакомых имён моделей |
| `timeout` | `300` | сколько секунд ждать **бездействия** страницы | | `timeout` | `300` | сколько секунд ждать **бездействия** страницы |
Про `timeout`: это не «сколько ждать ответа». Пока DeepSeek печатает, ждём Про `timeout`: это не «сколько ждать ответа». Пока DeepSeek печатает, ждём
@@ -383,7 +389,7 @@ start.cmd
| Ответ приходит **обрезанным**, без кода | окно браузера свёрнуто — Windows перестаёт его отрисовывать, а DeepSeek дорисовывает блоки кода лениво | Разверните окно. Не сворачивайте: уводите на другой рабочий стол или за другие окна | | Ответ приходит **обрезанным**, без кода | окно браузера свёрнуто — Windows перестаёт его отрисовывать, а DeepSeek дорисовывает блоки кода лениво | Разверните окно. Не сворачивайте: уводите на другой рабочий стол или за другие окна |
| Ответ приходит **чужой**, не на ваш вопрос | вкладка не успела обновиться | Повторите запрос. Если повторяется — перезапустите браузер | | Ответ приходит **чужой**, не на ваш вопрос | вкладка не успела обновиться | Повторите запрос. Если повторяется — перезапустите браузер |
| В логе `правки в этом чате кончились, завожу новый` | исчерпан лимит в шесть правок одного сообщения | Ничего. Это штатная смена чата, см. [Чаты изнашиваются](#чаты-изнашиваются-и-меняются-сами) | | В логе `правки в этом чате кончились, завожу новый` | исчерпан лимит в шесть правок одного сообщения | Ничего. Это штатная смена чата, см. [Чаты изнашиваются](#чаты-изнашиваются-и-меняются-сами) |
| Чаты пропадают сами, хотя вы их не удаляли | сайдбар подчищается от всего, чего нет в `chats.json`, а чаты привязаны к **аккаунту** DeepSeek, а не к профилю браузера | Держите на этом аккаунте только рабочие чаты. Другой программе, управляющей чатами, нужен другой аккаунт — разными профилями браузера тут не разойтись | | Чаты пропадают сами, хотя вы их не удаляли | сайдбар подчищается от всего, кроме рабочего чата, а чаты привязаны к **аккаунту** DeepSeek, а не к профилю браузера | Держите на этом аккаунте только рабочие чаты. Другой программе, управляющей чатами, нужен другой аккаунт — разными профилями браузера тут не разойтись |
| `EADDRINUSE: address already in use` | сервис уже запущен в другом окне | Закройте то окно либо возьмите другой порт (`DEEPSEEK_API_PORT`) | | `EADDRINUSE: address already in use` | сервис уже запущен в другом окне | Закройте то окно либо возьмите другой порт (`DEEPSEEK_API_PORT`) |
| `node не является внутренней или внешней командой` | Node не установлен или командная строка открыта до его установки | Поставьте Node, **закройте и откройте заново** командную строку | | `node не является внутренней или внешней командой` | Node не установлен или командная строка открыта до его установки | Поставьте Node, **закройте и откройте заново** командную строку |
| Просит войти в DeepSeek при каждом запуске | профиль каждый раз новый | Проверьте, что `profileDir` указывает на одну и ту же существующую папку | | Просит войти в DeepSeek при каждом запуске | профиль каждый раз новый | Проверьте, что `profileDir` указывает на одну и ту же существующую папку |
@@ -426,11 +432,11 @@ node test.mjs
POST /api/chat POST /api/chat
├─ склеить system + user в один текст (у веб-чата нет ролей) ├─ склеить system + user в один текст (у веб-чата нет ролей)
├─ выбрать чат по имени модели (think / search / vision)
├─ встать в очередь (вкладка одна) ├─ встать в очередь (вкладка одна)
├─> поднять браузер, если он не отвечает ├─> поднять браузер, если он не отвечает
├─> Chrome DevTools Protocol ├─> Chrome DevTools Protocol
│ ├─> выключить DeepThink
│ └─> правка ПЕРВОГО сообщения чата │ └─> правка ПЕРВОГО сообщения чата
│ └─> дождаться, пока DeepSeek допечатает │ └─> дождаться, пока DeepSeek допечатает
@@ -452,17 +458,17 @@ POST /api/chat
правки кнопка перестаёт открывать поле ввода — и чат становится непригоден. правки кнопка перестаёт открывать поле ввода — и чат становится непригоден.
Ничего делать не нужно, это предусмотрено. На седьмом вопросе программа Ничего делать не нужно, это предусмотрено. На седьмом вопросе программа
заводит новый чат того же режима, задаёт вопрос сразу в нём, записывает новый заводит новый чат, задаёт вопрос сразу в нём, записывает новый номер
номер в `chats.json` и **удаляет отработавший чат**. Снаружи не видно ничего: в `chats.json` и **удаляет отработавший чат**. Снаружи не видно ничего:
тот же запрос, тот же ответ, разве что на несколько секунд дольше. тот же запрос, тот же ответ, разве что на несколько секунд дольше.
Отсюда следствия: Отсюда следствия:
- **`chats.json` — не настройка, а переменная.** Номера в нём меняются сами - **`chats.json` — не настройка, а переменная.** Номер в нём меняется сам
по мере работы. Не правьте его руками; в репозиторий он не попадает, по мере работы. Не правьте его руками; в репозиторий он не попадает,
образец структуры — `chats.example.json`. образец структуры — `chats.example.json`.
- **В сайдбаре DeepSeek не копится мусор.** Старые чаты удаляются, плюс перед - **В сайдбаре DeepSeek не копится мусор.** Старые чаты удаляются, плюс перед
каждым вопросом сайдбар подчищается от всего, чего нет в `chats.json`: каждым вопросом сайдбар подчищается от всего, кроме рабочего чата:
всё чужое программа считает мусором. Чистка идёт по **аккаунту** — чаты всё чужое программа считает мусором. Чистка идёт по **аккаунту** — чаты
хранятся на сервере DeepSeek, и сменой профиля браузера от неё не укрыться. хранятся на сервере DeepSeek, и сменой профиля браузера от неё не укрыться.
Поэтому аккаунт под сервис отдавайте целиком. Поэтому аккаунт под сервис отдавайте целиком.
@@ -476,7 +482,7 @@ POST /api/chat
| `server.mjs` | HTTP, перевод формата Ollama, очередь | | `server.mjs` | HTTP, перевод формата Ollama, очередь |
| `browser.mjs` | всё про браузер: CDP, разметка DeepSeek, отправка, ожидание ответа, чаты | | `browser.mjs` | всё про браузер: CDP, разметка DeepSeek, отправка, ожидание ответа, чаты |
| `config.json` | настройки; `config.local.json` рядом перебивает их | | `config.json` | настройки; `config.local.json` рядом перебивает их |
| `chats.json` | рабочее состояние: id живых чатов (заводится сам) | | `chats.json` | рабочее состояние: id рабочего чата (заводится сам) |
| `start.cmd` | запуск в консоли | | `start.cmd` | запуск в консоли |
| `tray.cs`, `build.cmd`, `icon.ico` | окно с индикатором в трее; `.exe` собирается на месте | | `tray.cs`, `build.cmd`, `icon.ico` | окно с индикатором в трее; `.exe` собирается на месте |
| `test.mjs` | проверка перевода формата, браузер не нужен | | `test.mjs` | проверка перевода формата, браузер не нужен |
+96 -252
View File
@@ -29,14 +29,14 @@ export function configure({ cdpPort, browserExe, profileDir, chatUrl }) {
if (chatUrl) BROWSER.url = chatUrl; if (chatUrl) BROWSER.url = chatUrl;
} }
// Роли — это отдельные чаты под разные режимы. Режим выбирается при СОЗДАНИИ // Модель у DeepSeek теперь одна и универсальная: выбор из трёх (Instant /
// чата и потом не меняется: тумблеры исчезают после первого сообщения. // Expert / Vision) убран из веб-морды, картинки разбирает та же модель.
export const ROLES = ['think', 'search', 'vision']; // Поэтому и чат нужен один — рабочий, расходный.
// Роль → id чата из chats.json. Файл — рабочее состояние, а не настройка: // Id рабочего чата из chats.json. Файл — состояние, а не настройка: он
// он заводится сам и переписывается при каждой смене чата. // заводится сам и переписывается, когда чат меняется.
async function chatsMap() { async function savedChatId() {
try { return JSON.parse(await readFile(CHATS_FILE, 'utf8')); } catch { return {}; } try { return JSON.parse(await readFile(CHATS_FILE, 'utf8')).id || null; } catch { return null; }
} }
@@ -181,96 +181,21 @@ function __answerNodes() {
} }
return { sel: null, nodes: [] }; return { sel: null, nodes: [] };
} }
// Тумблеры DeepThink и Search рядом с полем ввода. Раньше они пропадали, как
// только в чате появлялось первое сообщение, — на этом держалась вся прежняя
// схема с отдельным чатом под каждый режим. Проверено 11.09.2026: теперь они
// видны и переключаются в любом чате, в том числе начатом.
//
// Разметка: div.ds-toggle-button с aria-pressed. Вложенная иконка несёт класс
// ds-toggle-button__icon — это не тумблер, кликать по ней бессмысленно.
function __toggles() { function __toggles() {
// В диалоге подписи пропадают и остаются иконки, поэтому опора на класс тумблера, return [...document.querySelectorAll('.ds-toggle-button')]
// а текст/подсказка — лишь для имени.
return [...document.querySelectorAll('div[role="button"], button, [class*="toggle-button"]')]
.filter(__visible) .filter(__visible)
.filter(e => { .filter(e => !/ds-toggle-button__/.test(e.getAttribute('class') || ''))
const c = e.getAttribute('class') || ''; .map(e => ({
if (/toggle-button__/.test(c)) return false; // вложенные иконки, не сами тумблеры name: (e.innerText || '').trim() || '(без подписи)',
return /ds-toggle-button/.test(c) || /^(DeepThink|Search)$/i.test((e.innerText || '').trim()); on: e.getAttribute('aria-pressed') === 'true',
}) }));
.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())
})) };
}
// Сайдбар без <a>: берём кликабельные строки с текстом из левой колонки.
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 глотает молча, // Причина найдена замером 26.08.2026: btn.click() из DOM React глотает молча,
@@ -288,17 +213,15 @@ function __newChatBox() {
// data-virtual-list-item-key. Ответ отличается от вопроса тем, что внутри // data-virtual-list-item-key. Ответ отличается от вопроса тем, что внутри
// него есть .ds-markdown. // него есть .ds-markdown.
// //
// За номер ключа цепляться НЕЛЬЗЯ: после каждой правки список // За номер ключа цепляться НЕЛЬЗЯ, и сортировать по нему тоже: номера живут
// перенумеровывается (было 1/2, стало 3/4), а невидимых элементов в DOM // своей жизнью — после правок список перенумеровывается, а у вопросов они
// вообще нет — виртуализация их выбрасывает. Берём самый ранний из // теперь и вовсе отрицательные (замер 11.09.2026: -4, 2, -11, 8 для двух
// отрисованных вопросов. После правки в чате остаётся ровно один обмен, // обменов подряд). Сортировка по возрастанию отдавала ПОСЛЕДНИЙ вопрос вместо
// поэтому такой вопрос там один. // первого, и правка уходила не в то сообщение. Берём порядок DOM: список
// рисуется сверху вниз, значит первый отрисованный вопрос — он и есть.
function __firstMsg() { function __firstMsg() {
const items = [...document.querySelectorAll('[data-virtual-list-item-key]')] return [...document.querySelectorAll('[data-virtual-list-item-key]')]
.map(e => ({ e, k: Number(e.getAttribute('data-virtual-list-item-key')) })) .find(e => !e.querySelector('.ds-markdown')) || null;
.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. // и прячутся, когда курсор уходит вниз к ним, поэтому жать только из DOM.
@@ -628,92 +551,41 @@ async function openChatById(s, id, log) {
throw new Error(`не удалось открыть чат ${id} — страница не загрузилась.`); throw new Error(`не удалось открыть чат ${id} — страница не загрузилась.`);
} }
// Настоящий клик мышью по координатам: el.click() у DeepSeek только красит // Щёлкнуть по тумблеру настоящим мышиным событием. DOM-вызов click() тут
// тумблер, но модель чата не меняет — проверяется по индикатору Instant/DeepThink. // тоже срабатывает, но мышь проверена на большем числе случаев и работает
async function realClick(s, name) { // даже там, где React глотает синтетический клик.
async function clickToggle(s, name) {
const box = await s.eval(`(() => { ${PAGE_HELPERS} const box = await s.eval(`(() => { ${PAGE_HELPERS}
const el = [...document.querySelectorAll('div[role="button"], button, [class*="toggle-button"]')] const el = [...document.querySelectorAll('.ds-toggle-button')]
.filter(__visible) .filter(__visible)
.filter(e => !/toggle-button__/.test(e.getAttribute('class') || '')) .filter(e => !/ds-toggle-button__/.test(e.getAttribute('class') || ''))
.find(e => (e.innerText || '').trim().toLowerCase() === ${JSON.stringify(name)}.toLowerCase()); .find(e => (e.innerText || '').trim().toLowerCase() === ${JSON.stringify(name)}.toLowerCase());
if (!el) return null; if (!el) return null;
const r = el.getBoundingClientRect(); const r = el.getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
})()`); })()`);
if (!box) return false; if (!box) return false;
const p = { x: box.x, y: box.y, button: 'left', clickCount: 1 }; await hoverClick(s, box);
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); await sleep(600);
return true; return true;
} }
// Клик мышью по видимому элементу с ТОЧНЫМ текстом (пункт меню моделей). // DeepThink выключаем перед каждым вопросом: он только показывает размышления
async function clickExactText(s, text) { // модели и раздувает ответ, а модель и без него та же самая — выбора моделей
const box = await s.eval(`(() => { ${PAGE_HELPERS} // у DeepSeek больше нет.
const el = [...document.querySelectorAll('div, span, li, button')].filter(__visible) //
.filter(e => (e.innerText || '').trim() === ${JSON.stringify(text)}) // Search НЕ трогаем намеренно: это тумблер человека. Как поставлен во вкладке,
.sort((a, b) => a.getBoundingClientRect().width - b.getBoundingClientRect().width)[0]; // так мост и спрашивает — иначе мост отнимал бы единственный оставшийся
if (!el) return null; // способ управлять поиском в интернете.
const r = el.getBoundingClientRect(); async function ensureNoDeepThink(s, log) {
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; const isOn = () => s.eval(`(() => { ${PAGE_HELPERS}
})()`); return __toggles().some(t => /deepthink/i.test(t.name) && t.on); })()`);
if (!box) return false; if (!await isOn()) return;
const p = { x: box.x, y: box.y, button: 'left', clickCount: 1 }; if (!await clickToggle(s, 'DeepThink')) {
await s.send('Input.dispatchMouseEvent', { ...p, type: 'mouseMoved', button: 'none', clickCount: 0 }); log('тумблер DeepThink не найден — оставляю как есть');
await sleep(120); return;
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} log(await isOn() ? 'DeepThink выключить не удалось' : 'DeepThink выключен');
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;
} }
// Ответ считается готовым, когда его текст перестал меняться. // Ответ считается готовым, когда его текст перестал меняться.
@@ -925,15 +797,11 @@ async function deleteChat(s, id, log = () => {}) {
// Стоит недорого: список чатов — один eval, и если лишнего нет, дальше ничего // Стоит недорого: список чатов — один eval, и если лишнего нет, дальше ничего
// не происходит. // не происходит.
async function pruneChats(s, log = () => {}) { async function pruneChats(s, log = () => {}) {
let keep = new Set(); const keep = await savedChatId();
try { if (!keep) return 0; // не знаем, что беречь, — не трогаем ничего
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 all = await s.eval(`(() => { ${PAGE_HELPERS} return __chatIds(); })()`);
const extra = all.filter(c => !keep.has(c.id)); const extra = all.filter(c => c.id !== keep);
if (!extra.length) return 0; if (!extra.length) return 0;
log(`лишних чатов в сайдбаре: ${extra.length}, убираю`); log(`лишних чатов в сайдбаре: ${extra.length}, убираю`);
@@ -946,68 +814,48 @@ async function pruneChats(s, log = () => {}) {
return 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 разрешает править первое сообщение лишь ШЕСТЬ раз, дальше кнопка // DeepSeek разрешает править первое сообщение лишь ШЕСТЬ раз, дальше кнопка
// правки перестаёт открывать поле. Поэтому чат — расходник: как только правки // правки перестаёт открывать поле. Поэтому чат — расходник: как только правки
// кончились, заводим новый с тем же режимом и переписываем chats.json. // кончились, заводим новый и переписываем chats.json.
// //
// firstText — настоящий вопрос вместо приветствия. Раньше чат // Чат сразу открывается настоящим вопросом. Раньше он открывался служебным
// всегда открывался служебной фразой, а вопрос уходил ПРАВКОЙ этой фразы: круг // приветствием, а вопрос уходил ПРАВКОЙ этой фразы: круг DeepSeek тратился
// DeepSeek тратился впустую и одна правка из шести — тоже. Режим чата и его id // впустую и одна правка из шести — тоже.
// появляются от любого первого сообщения, так что якорь не нужен. async function makeChat(s, log, firstText, timeout = 300, imageBytes = null) {
// Возвращает { 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 clickNewChat(s, log);
await sleep(1500); await sleep(1500);
await ensureModel(s, step.model, step.toggle, log); await ensureNoDeepThink(s, log);
const put = await s.eval(SET_TEXT(firstText || step.hello)); const put = await s.eval(SET_TEXT(firstText));
if (!put.ok) throw new Error(put.reason); if (!put.ok) throw new Error(put.reason);
if (imageBytes) await attachImage(s, imageBytes, log);
await sleep(300); await sleep(300);
await sendAndConfirm(s, log); await sendAndConfirm(s, log);
const answer = await waitForAnswer(s, put.before, const answer = await waitForAnswer(s, put.before, timeout, log, put.textBefore);
firstText ? timeout : 420, log, put.textBefore);
// Ждём id, отличный от прежнего. Просто «непустой» тут мало: если переход // Ждём id, отличный от прежнего. Просто «непустой» тут мало: если переход
// не состоялся, в адресе остаётся старый id, и он выглядит как новый. // не состоялся, в адресе остаётся старый id, и он выглядит как новый.
const mapFile = CHATS_FILE; const old = await savedChatId();
let prev = null;
try { prev = JSON.parse(await readFile(mapFile, 'utf8'))[role]?.id ?? null; } catch { /* нет файла */ }
let id = null; let id = null;
for (let i = 0; i < 20; i++) { for (let i = 0; i < 20; i++) {
id = await s.eval(`(() => { ${PAGE_HELPERS} return __chatId(); })()`); id = await s.eval(`(() => { ${PAGE_HELPERS} return __chatId(); })()`);
if (id && id !== prev) break; if (id && id !== old) break;
id = null; id = null;
await sleep(500); await sleep(500);
} }
if (!id) throw new Error('новый чат создан, но его id не появился в адресе'); if (!id) throw new Error('новый чат создан, но его id не появился в адресе');
const file = mapFile; await writeFile(CHATS_FILE, JSON.stringify({ id }, null, 2), 'utf8');
let map = {}; log(`создан новый чат: ${id}`);
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) { if (old && old !== id) {
try { await deleteChat(s, old, log); } try { await deleteChat(s, old, log); }
catch (e) { log(`старый чат ${old} удалить не вышло: ${e.message}`); } catch (e) { log(`старый чат ${old} удалить не вышло: ${e.message}`); }
} }
return { id, answer, answered: !!firstText }; return { id, answer };
} }
// ================================================================== // ==================================================================
@@ -1156,14 +1004,13 @@ async function attachImage(s, bytes, log) {
// Один вопрос — один ответ. Всё остальное (какой чат, жив ли он, не кончились // Один вопрос — один ответ. Всё остальное (какой чат, жив ли он, не кончились
// ли правки) решается здесь и наружу не торчит. // ли правки) решается здесь и наружу не торчит.
export async function ask({ prompt, role = 'think', imageBytes = null, export async function ask({ prompt, imageBytes = null,
timeout = 300, log = () => {} }) { timeout = 300, log = () => {} }) {
if (!String(prompt || '').trim()) throw new Error('пустой вопрос'); if (!String(prompt || '').trim()) throw new Error('пустой вопрос');
if (!ROLES.includes(role)) role = 'think';
await ensureBrowser(log); await ensureBrowser(log);
try { try {
return await askOnce({ prompt, role, imageBytes, timeout, log }); return await askOnce({ prompt, imageBytes, timeout, log });
} catch (e) { } catch (e) {
if (!isLostTab(e)) throw e; if (!isLostTab(e)) throw e;
// Вкладку закрыли или браузер уронили посреди работы. Поднимаем заново // Вкладку закрыли или браузер уронили посреди работы. Поднимаем заново
@@ -1174,53 +1021,49 @@ export async function ask({ prompt, role = 'think', imageBytes = null,
// успела отправить вопрос, вторая его перепишет, а не задаст дважды. // успела отправить вопрос, вторая его перепишет, а не задаст дважды.
log(`связь с вкладкой потеряна (${e.message}); поднимаю и повторяю`); log(`связь с вкладкой потеряна (${e.message}); поднимаю и повторяю`);
await ensureBrowser(log); await ensureBrowser(log);
return askOnce({ prompt, role, imageBytes, timeout, log }); return askOnce({ prompt, imageBytes, timeout, log });
} }
} }
async function askOnce({ prompt, role, imageBytes, timeout, log }) { async function askOnce({ prompt, imageBytes, timeout, log }) {
return withChat(async (s) => { return withChat(async (s) => {
await pruneChats(s, log); // сайдбар держим чистым await pruneChats(s, log); // сайдбар держим чистым
const known = (await chatsMap())[role]?.id ?? null; const known = await savedChatId();
const alive = known && await s.eval(`(() => { ${PAGE_HELPERS} const alive = known && await s.eval(`(() => { ${PAGE_HELPERS}
return __chatIds().some(c => c.id === ${JSON.stringify(String(known))}); })()`); return __chatIds().some(c => c.id === ${JSON.stringify(String(known))}); })()`);
// Чата нужного режима нет — заводим сразу с вопросом. Отдельное приветствие // Картинка — всегда НОВЫЙ чат. Вложение цепляется к полю ввода, а правка
// стоило бы лишнего круга DeepSeek и одной правки из шести, а нужно не было: // идёт в отдельном поле над ним: в старый чат картинку не перенести, и
// режим и id чата появляются от любого первого сообщения. // отправка новым сообщением ломала единственный уговор, на котором всё
if (!alive) { // держится, — «в чате ровно один обмен». Поймано 11.09.2026: после такого
log(`чат ${role} не найден, создаю сразу с вопросом`); // сообщения чат оставался из двух обменов, правка уходила в чужой вопрос,
const made = await makeChat(s, role, log, prompt, timeout); // а наружу отдавался ответ на предыдущий.
//
// Рабочего чата нет — тоже заводим, сразу с вопросом. Отдельное приветствие
// стоило бы лишнего круга DeepSeek и одной правки из шести, а нужно
// не было: id чата появляется от любого первого сообщения.
if (!alive || imageBytes) {
log(imageBytes ? 'вопрос с картинкой — завожу новый чат'
: 'рабочий чат не найден, создаю сразу с вопросом');
const made = await makeChat(s, log, prompt, timeout, imageBytes);
return made.answer; return made.answer;
} }
await openChatById(s, String(known), log); await openChatById(s, String(known), log);
if (imageBytes) await attachImage(s, imageBytes, log); await ensureNoDeepThink(s, log);
// С картинкой — только новым сообщением: вложение попадает в поле ввода,
// а правка идёт в отдельном поле над ним, и картинка туда не переезжает.
const canEdit = !imageBytes && await s.eval(
`(() => { ${PAGE_HELPERS} return !!__firstMsg(); })()`);
if (canEdit) {
try { try {
const sent = await editFirstAndSend(s, prompt, log); const sent = await editFirstAndSend(s, prompt, log);
return await waitForAnswer(s, sent.before, timeout, log, sent.textBefore); return await waitForAnswer(s, sent.before, timeout, log, sent.textBefore);
} catch (e) { } catch (e) {
// Единственная ожидаемая причина исчерпан лимит правок (их шесть). // Ожидаемых причин две: исчерпан лимит правок (их шесть) и чат без
if (!/поле правки не открылось/.test(String(e.message || e))) throw e; // единого сообщения — например, его завели руками.
log('правки в этом чате кончились, завожу новый сразу с вопросом'); if (!/поле правки не открылось|первое сообщение чата не найдено/.test(String(e.message || e))) throw e;
const made = await makeChat(s, role, log, prompt, timeout); log('править нечего или правки кончились, завожу новый чат сразу с вопросом');
const made = await makeChat(s, log, prompt, timeout);
return made.answer; 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);
}); });
} }
@@ -1236,9 +1079,10 @@ export async function probe() {
answerBlocks: a.nodes.length, answerBlocks: a.nodes.length,
generating: __isGenerating(), generating: __isGenerating(),
chats: __chatIds(), chats: __chatIds(),
toggles: __toggles(),
needsLogin: /log ?in|войти|sign in/i.test(document.body.innerText.slice(0, 3000)), needsLogin: /log ?in|войти|sign in/i.test(document.body.innerText.slice(0, 3000)),
}; };
})()`); })()`);
return { tab: tab.url, ...info, chatsFile: await chatsMap() }; return { tab: tab.url, ...info, workChat: await savedChatId() };
}); });
} }
+1 -12
View File
@@ -1,14 +1,3 @@
{ {
"think": { "id": "00000000-0000-0000-0000-000000000000"
"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"
}
} }
-1
View File
@@ -5,6 +5,5 @@
"browserExe": "C:\\Program Files\\Yandex\\YandexBrowser\\Application\\browser.exe", "browserExe": "C:\\Program Files\\Yandex\\YandexBrowser\\Application\\browser.exe",
"profileDir": "C:\\Browsers\\deepseek", "profileDir": "C:\\Browsers\\deepseek",
"chatUrl": "https://chat.deepseek.com/", "chatUrl": "https://chat.deepseek.com/",
"defaultRole": "think",
"timeout": 300 "timeout": 300
} }
+17 -23
View File
@@ -16,7 +16,7 @@ import { readFile } from 'node:fs/promises';
import { join, dirname } from 'node:path'; import { join, dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url'; import { fileURLToPath, pathToFileURL } from 'node:url';
import { configure, ask, probe, revive, browserAlive, ROLES } from './browser.mjs'; import { configure, ask, probe, revive, browserAlive } from './browser.mjs';
// fileURLToPath, а не разбор pathname вручную: путь проекта может содержать «#», // fileURLToPath, а не разбор pathname вручную: путь проекта может содержать «#»,
// и в URL он приезжает как %23 — самодельный разбор ломается именно на нём. // и в URL он приезжает как %23 — самодельный разбор ломается именно на нём.
@@ -29,7 +29,6 @@ const cfg = {
browserExe: '', browserExe: '',
profileDir: '', profileDir: '',
chatUrl: 'https://chat.deepseek.com/', chatUrl: 'https://chat.deepseek.com/',
defaultRole: 'think',
timeout: 300, timeout: 300,
// Сторож: поднимать браузер и вкладку сам, не дожидаясь вопроса. Иначе // Сторож: поднимать браузер и вкладку сам, не дожидаясь вопроса. Иначе
// восстановление происходит внутри запроса, и клиент платит за него // восстановление происходит внутри запроса, и клиент платит за него
@@ -50,7 +49,10 @@ if (process.env.DEEPSEEK_CDP_PORT) cfg.cdpPort = Number(process.env.DEEPSEEK_CDP
configure(cfg); configure(cfg);
const MODELS = ROLES.map(r => 'deepseek:' + r); // Модель одна: выбор из трёх (Instant / Expert / Vision) DeepSeek из веб-морды
// убрал, оставшаяся универсальна — она же разбирает картинки. Имя модели,
// которое пришлёт клиент, мы не смотрим вовсе.
const MODEL = 'deepseek:chat';
// Состояние для индикаторов. Обновляется сторожем и запросами; отдаётся // Состояние для индикаторов. Обновляется сторожем и запросами; отдаётся
// по /api/state, НЕ трогая вкладку — на него можно смотреть хоть раз в секунду. // по /api/state, НЕ трогая вкладку — на него можно смотреть хоть раз в секунду.
@@ -115,14 +117,6 @@ async function runWatch() {
// ------------------------------------------------------------------ Ollama // ------------------------------------------------------------------ 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 отдельными сообщениями, у чата // Ollama принимает роли system/user/assistant отдельными сообщениями, у чата
// DeepSeek такого разделения нет — склеиваем в один текст. Роль assistant тоже // DeepSeek такого разделения нет — склеиваем в один текст. Роль assistant тоже
// сохраняем: в редких сценариях клиент подкладывает начало ответа. // сохраняем: в редких сценариях клиент подкладывает начало ответа.
@@ -200,10 +194,10 @@ const server = createServer(async (req, res) => {
}); });
} }
return json(res, 200, { return json(res, 200, {
models: MODELS.map(m => ({ models: [{
name: m, model: m, modified_at: now(), size: 0, name: MODEL, model: MODEL, modified_at: now(), size: 0,
details: { family: 'deepseek', parameter_size: 'web', quantization_level: '-' }, details: { family: 'deepseek', parameter_size: 'web', quantization_level: '-' },
})), }],
}); });
} }
@@ -232,16 +226,17 @@ const server = createServer(async (req, res) => {
const prompt = flatten(body.messages); const prompt = flatten(body.messages);
if (!prompt) return json(res, 400, { error: 'пустой запрос' }); if (!prompt) return json(res, 400, { error: 'пустой запрос' });
const imageBytes = imageOf(body.messages); const imageBytes = imageOf(body.messages);
const role = imageBytes ? 'vision' : roleOf(body.model);
const started = Date.now(); const started = Date.now();
console.log(`[${role}] ${prompt.length} симв.${imageBytes ? ' + картинка' : ''}`); console.log(`вопрос ${prompt.length} симв.${imageBytes ? ' + картинка' : ''}`);
const answer = await serial(() => track(() => ask({ const answer = await serial(() => track(() => ask({
prompt, role, imageBytes, timeout: cfg.timeout, prompt, imageBytes, timeout: cfg.timeout,
log: m => console.log(` ${m}`), log: m => console.log(` ${m}`),
}))); })));
console.log(`[${role}] ответ ${answer.length} симв. за ${ console.log(`ответ ${answer.length} симв. за ${
Math.round((Date.now() - started) / 1000)} с`); Math.round((Date.now() - started) / 1000)} с`);
const model = body.model || ('deepseek:' + role); // Имя модели возвращаем то, которое прислал клиент: иначе он решит, что
// ответила не та модель, которую он просил.
const model = body.model || MODEL;
const head = { model, created_at: now() }; const head = { model, created_at: now() };
if (body.stream === false) { if (body.stream === false) {
return json(res, 200, { ...head, message: { role: 'assistant', content: answer }, return json(res, 200, { ...head, message: { role: 'assistant', content: answer },
@@ -258,11 +253,10 @@ const server = createServer(async (req, res) => {
const body = await readBody(req); const body = await readBody(req);
const prompt = [body.system, body.prompt].filter(Boolean).join('\n\n').trim(); const prompt = [body.system, body.prompt].filter(Boolean).join('\n\n').trim();
if (!prompt) return json(res, 400, { error: 'пустой запрос' }); if (!prompt) return json(res, 400, { error: 'пустой запрос' });
const role = roleOf(body.model);
const answer = await serial(() => track(() => ask({ const answer = await serial(() => track(() => ask({
prompt, role, timeout: cfg.timeout, log: m => console.log(` ${m}`), prompt, timeout: cfg.timeout, log: m => console.log(` ${m}`),
}))); })));
const head = { model: body.model || ('deepseek:' + role), created_at: now() }; const head = { model: body.model || MODEL, created_at: now() };
if (body.stream === false) { if (body.stream === false) {
return json(res, 200, { ...head, response: answer, done: true, done_reason: 'stop' }); return json(res, 200, { ...head, response: answer, done: true, done_reason: 'stop' });
} }
@@ -296,7 +290,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
server.listen(cfg.port, cfg.host, () => { server.listen(cfg.port, cfg.host, () => {
console.log(`DeepSeek прикидывается Ollama: http://localhost:${cfg.port}`); console.log(`DeepSeek прикидывается Ollama: http://localhost:${cfg.port}`);
if (cfg.host !== '0.0.0.0') console.log(`Слушаем только ${cfg.host}`); if (cfg.host !== '0.0.0.0') console.log(`Слушаем только ${cfg.host}`);
console.log(`Модели: ${MODELS.join(', ')}`); console.log(`Модель: ${MODEL}`);
console.log(`Браузер: порт ${cfg.cdpPort}, профиль ${cfg.profileDir || '(не задан)'}`); console.log(`Браузер: порт ${cfg.cdpPort}, профиль ${cfg.profileDir || '(не задан)'}`);
if (cfg.watchdog) { if (cfg.watchdog) {
console.log(`Сторож: проверка каждые ${cfg.watchdogSec} с`); console.log(`Сторож: проверка каждые ${cfg.watchdogSec} с`);
+1 -8
View File
@@ -1,14 +1,7 @@
// Проверка перевода формата Ollama в один вопрос для веб-чата. // Проверка перевода формата Ollama в один вопрос для веб-чата.
// Браузер и сам DeepSeek тут не нужны: node test.mjs // Браузер и сам DeepSeek тут не нужны: node test.mjs
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { roleOf, flatten, imageOf } from './server.mjs'; import { 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 роли — отдельные сообщения, у веб-чата их нет: склеиваем по порядку, // У Ollama роли — отдельные сообщения, у веб-чата их нет: склеиваем по порядку,
// пустые выбрасываем. Потеря system означала бы потерю всей инструкции. // пустые выбрасываем. Потеря system означала бы потерю всей инструкции.