Одна модель вместо трёх: 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:
co-authored by
Claude Opus 5
parent
adbcedb485
commit
6ccb5dd46b
@@ -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
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
log(await isOn() ? 'DeepThink выключить не удалось' : 'DeepThink выключен');
|
||||||
// У DeepSeek два независимых механизма: выпадающий выбор МОДЕЛИ
|
|
||||||
// (Instant / Expert / Vision) и тумблеры DeepThink / Search поверх неё.
|
|
||||||
async function ensureModel(s, wantModel, wantToggle, log) {
|
|
||||||
const read = async () => (await s.eval(`(() => { ${PAGE_HELPERS} return __modelLabel(); })()`))[0] || '?';
|
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
||||||
const cur = await read();
|
|
||||||
if (cur.toLowerCase() === wantModel.toLowerCase()) break;
|
|
||||||
if (attempt === 3) {
|
|
||||||
throw new Error(`не удалось выбрать модель ${wantModel} — выбрана ${cur}.`);
|
|
||||||
}
|
|
||||||
const box = await s.eval(`(() => { ${PAGE_HELPERS}
|
|
||||||
const el = document.querySelector('[data-model-type="${wantModel}"][role="radio"]');
|
|
||||||
if (!el || !__visible(el)) return null;
|
|
||||||
const r = el.getBoundingClientRect();
|
|
||||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
|
||||||
})()`);
|
|
||||||
if (!box) throw new Error(`переключатель модели ${wantModel} не найден (чат уже начат?)`);
|
|
||||||
const p = { x: box.x, y: box.y, button: 'left', clickCount: 1 };
|
|
||||||
await s.send('Input.dispatchMouseEvent', { ...p, type: 'mouseMoved', button: 'none', clickCount: 0 });
|
|
||||||
await sleep(120);
|
|
||||||
await s.send('Input.dispatchMouseEvent', { ...p, type: 'mousePressed' });
|
|
||||||
await s.send('Input.dispatchMouseEvent', { ...p, type: 'mouseReleased' });
|
|
||||||
await sleep(700);
|
|
||||||
log(`выбор модели: попытка ${attempt} → ${await read()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wantToggle) {
|
|
||||||
const before = await s.eval(`(() => { ${PAGE_HELPERS} return __toggles(); })()`);
|
|
||||||
const t = before.find(x => x.name.toLowerCase() === wantToggle.toLowerCase());
|
|
||||||
if (t && !t.on) {
|
|
||||||
await realClick(s, wantToggle);
|
|
||||||
const after = await s.eval(`(() => { ${PAGE_HELPERS} return __toggles(); })()`);
|
|
||||||
const now = after.find(x => x.name.toLowerCase() === wantToggle.toLowerCase());
|
|
||||||
log(`тумблер ${wantToggle}: ${now?.on ? 'включён' : 'НЕ включился'}`);
|
|
||||||
} else if (t?.on) {
|
|
||||||
log(`тумблер ${wantToggle}: уже включён`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const model = await read();
|
|
||||||
const toggles = await s.eval(`(() => { ${PAGE_HELPERS} return __toggles(); })()`);
|
|
||||||
const on = toggles.filter(x => x.on).map(x => x.name);
|
|
||||||
return on.length ? `${model}+${on.join('+')}` : model;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ответ считается готовым, когда его текст перестал меняться.
|
// Ответ считается готовым, когда его текст перестал меняться.
|
||||||
@@ -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
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,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 означала бы потерю всей инструкции.
|
||||||
|
|||||||
Reference in New Issue
Block a user