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