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