From 6ccb5dd46b3163a5fcb01916fa35c79d1541e556 Mon Sep 17 00:00:00 2001 From: Alex Cube Date: Fri, 11 Sep 2026 10:26:03 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=B4=D0=BD=D0=B0=20=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20?= =?UTF-8?q?=D1=82=D1=80=D1=91=D1=85:=20DeepSeek=20=D1=83=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=BB=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D0=B8=D0=B7=20=D0=B2?= =?UTF-8?q?=D0=B5=D0=B1-=D0=BC=D0=BE=D1=80=D0=B4=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Ez8B2cR3mbm1fjmAMa8JXS --- README.md | 78 +++++----- browser.mjs | 358 +++++++++++++-------------------------------- chats.example.json | 13 +- config.json | 1 - server.mjs | 40 +++-- test.mjs | 9 +- 6 files changed, 162 insertions(+), 337 deletions(-) diff --git a/README.md b/README.md index 8969c95..14bfd0b 100644 --- a/README.md +++ b/README.md @@ -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` | проверка перевода формата, браузер не нужен | diff --git a/browser.mjs b/browser.mjs index 103b083..44cc275 100644 --- a/browser.mjs +++ b/browser.mjs @@ -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()) - })) }; - } - // Сайдбар без : берём кликабельные строки с текстом из левой колонки. - 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}.`); - } - 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()}`); +// 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; } - - 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); - return made.answer; - } + 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, 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() }; }); } diff --git a/chats.example.json b/chats.example.json index 297b62d..5eac2ea 100644 --- a/chats.example.json +++ b/chats.example.json @@ -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" } diff --git a/config.json b/config.json index 9ea60a8..1e3b450 100644 --- a/config.json +++ b/config.json @@ -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 } diff --git a/server.mjs b/server.mjs index dd7ad07..f10f293 100644 --- a/server.mjs +++ b/server.mjs @@ -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} с`); diff --git a/test.mjs b/test.mjs index 03caee6..2e10e23 100644 --- a/test.mjs +++ b/test.mjs @@ -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 означала бы потерю всей инструкции.