Files
deepseek-api/browser.mjs
T
Alex CubeandClaude Opus 5 6ccb5dd46b Одна модель вместо трёх: 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
2026-09-11 10:26:03 +03:00

1089 lines
62 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Управление вкладкой DeepSeek через Chrome DevTools Protocol.
//
// Перенесено из ask.mjs проекта DeepShim (git.08h.ru/alex/deepshim), чтобы
// сервис был самодостаточным: там это часть большого инструмента с веб-пультом
// и разбором патчей, здесь нужен только чат. Всё, что удалось выяснить про
// разметку DeepSeek, сохранено вместе с комментариями — это знание дороже кода.
//
// Зависимостей нет: Node 22+ со встроенными fetch и WebSocket.
import { readFile, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve as resolvePath } from 'node:path';
import { existsSync } from 'node:fs';
import { spawn } from 'node:child_process';
const HERE = dirname(fileURLToPath(import.meta.url));
const CHATS_FILE = join(HERE, 'chats.json');
const CHAT_HOST = 'chat.deepseek.com';
// Настройки браузера приходят снаружи (из config.json сервиса) — модуль
// не лезет в чужие файлы и не знает, где живёт вызывающий.
let PORT = 9333;
let BROWSER = { exe: '', profileDir: '', url: `https://${CHAT_HOST}/` };
export function configure({ cdpPort, browserExe, profileDir, chatUrl }) {
if (cdpPort) PORT = Number(cdpPort);
if (browserExe !== undefined) BROWSER.exe = browserExe;
if (profileDir !== undefined) BROWSER.profileDir = profileDir;
if (chatUrl) BROWSER.url = chatUrl;
}
// Модель у DeepSeek теперь одна и универсальная: выбор из трёх (Instant /
// Expert / Vision) убран из веб-морды, картинки разбирает та же модель.
// Поэтому и чат нужен один — рабочий, расходный.
// Id рабочего чата из chats.json. Файл — состояние, а не настройка: он
// заводится сам и переписывается, когда чат меняется.
async function savedChatId() {
try { return JSON.parse(await readFile(CHATS_FILE, 'utf8')).id || null; } catch { return null; }
}
// ------------------------------------------------------------------ CDP
async function findChatTab() {
let targets;
try {
const res = await fetch(`http://127.0.0.1:${PORT}/json`, { signal: AbortSignal.timeout(3000) });
targets = await res.json();
} catch {
throw new Error(
`Браузер с отладочным портом ${PORT} не найден. Обычно его поднимает сам ` +
'сервис; если этого не произошло, проверьте browserExe и profileDir ' +
'в config.json.');
}
const pages = targets.filter(t => t.type === 'page');
const chat = pages.find(t => String(t.url).includes(CHAT_HOST));
if (!chat) {
throw new Error(
`Вкладка ${CHAT_HOST} не открыта. Открыто:\n` +
pages.map(p => ' ' + p.url).join('\n'));
}
return chat;
}
// Сколько ждать ОДИН вызов протокола. Обычные вызовы — чтение DOM, миллисекунды;
// самый долгий, Page.captureScreenshot на тяжёлой странице, укладывается в пару
// секунд. Минута — потолок, за которым уже не «медленно», а «не ответит никогда».
const CDP_CALL_TIMEOUT = 60;
const CDP_CONNECT_TIMEOUT = 10;
class Session {
#ws; #id = 1; #pending = new Map(); #dead = null;
static connect(wsUrl) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(wsUrl);
const s = new Session();
s.#ws = ws;
const giveUp = setTimeout(
() => reject(new Error(`вкладка не отозвалась за ${CDP_CONNECT_TIMEOUT} с`)),
CDP_CONNECT_TIMEOUT * 1000);
ws.addEventListener('open', () => { clearTimeout(giveUp); resolve(s); }, { once: true });
ws.addEventListener('error', e => {
clearTimeout(giveUp);
const err = new Error(`WebSocket: ${e?.message ?? e}`);
s.#fail(err); reject(err);
}, { once: true });
// Вкладку закрыли — все висящие вызовы обязаны отвалиться. Без этого
// обработчика они не разрешались НИКОГДА: запрос висел вечно, очередь
// за ним стояла, а сервис при этом выглядел живым и молчал.
ws.addEventListener('close', () => {
clearTimeout(giveUp);
s.#fail(new Error('вкладка DeepSeek закрылась во время работы'));
}, { once: true });
ws.addEventListener('message', ev => {
let m; try { m = JSON.parse(ev.data); } catch { return; }
if (m.id === undefined) return;
const p = s.#pending.get(m.id);
if (!p) return;
s.#pending.delete(m.id);
if (m.error) p.reject(new Error(m.error.message)); else p.resolve(m.result);
});
});
}
// Сокет умер: разрешить всё, что ждёт, и запомнить причину — следующие
// вызовы должны падать сразу, а не копиться на мёртвом сокете.
#fail(err) {
this.#dead = err;
for (const p of this.#pending.values()) p.reject(err);
this.#pending.clear();
}
send(method, params = {}) {
if (this.#dead) return Promise.reject(this.#dead);
const id = this.#id++;
return new Promise((resolve, reject) => {
const giveUp = setTimeout(() => {
this.#pending.delete(id);
reject(new Error(`вкладка не ответила на ${method} за ${CDP_CALL_TIMEOUT} с`));
}, CDP_CALL_TIMEOUT * 1000);
this.#pending.set(id, {
resolve: v => { clearTimeout(giveUp); resolve(v); },
reject: e => { clearTimeout(giveUp); reject(e); },
});
try {
this.#ws.send(JSON.stringify({ id, method, params }));
} catch (e) {
clearTimeout(giveUp);
this.#pending.delete(id);
reject(new Error(`не удалось отправить ${method}: ${e.message}`));
}
});
}
async eval(expression, awaitPromise = true) {
const r = await this.send('Runtime.evaluate', {
expression, returnByValue: true, awaitPromise, userGesture: true,
});
if (r.exceptionDetails) {
throw new Error(`JS на странице: ${r.exceptionDetails.exception?.description ?? r.exceptionDetails.text}`);
}
return r.result?.value;
}
close() { try { this.#ws.close(); } catch { } }
}
async function withChat(fn) {
const tab = await findChatTab();
const s = await Session.connect(tab.webSocketDebuggerUrl);
try { return await fn(s, tab); } finally { s.close(); }
}
// -------------------------------------------------- код, живущий на странице
// Поле ввода: у DeepSeek это textarea, но разметка меняется — ищем устойчиво.
const PAGE_HELPERS = `
function __visible(el) {
const r = el.getBoundingClientRect();
const cs = getComputedStyle(el);
return r.width > 0 && r.height > 0 && cs.visibility !== 'hidden' && cs.display !== 'none';
}
function __input() {
const cands = [
...document.querySelectorAll('textarea'),
...document.querySelectorAll('[contenteditable="true"]')
].filter(__visible);
if (cands.length === 0) return null;
// самое нижнее видимое поле — это строка ввода чата
return cands.sort((a, b) => b.getBoundingClientRect().top - a.getBoundingClientRect().top)[0];
}
function __answerNodes() {
const selectors = ['.ds-markdown', '[class*="markdown"]', '[data-message-id]', '[class*="message-content"]'];
for (const sel of selectors) {
const found = [...document.querySelectorAll(sel)].filter(__visible);
if (found.length) return { sel, nodes: found };
}
return { sel: null, nodes: [] };
}
// Тумблеры DeepThink и Search рядом с полем ввода. Раньше они пропадали, как
// только в чате появлялось первое сообщение, — на этом держалась вся прежняя
// схема с отдельным чатом под каждый режим. Проверено 11.09.2026: теперь они
// видны и переключаются в любом чате, в том числе начатом.
//
// Разметка: div.ds-toggle-button с aria-pressed. Вложенная иконка несёт класс
// ds-toggle-button__icon — это не тумблер, кликать по ней бессмысленно.
function __toggles() {
return [...document.querySelectorAll('.ds-toggle-button')]
.filter(__visible)
.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 глотает молча,
// адрес не менялся, а __newChat отчитывался успехом. Дальше makeChat считывал
// СТАРЫЙ id, считал его новым, ничего не удалял, и вопрос уходил в тот же чат.
// Наружу это выглядело как «ротация не работает».
function __newChatBox() {
const btn = [...document.querySelectorAll('div, button, a')].filter(__visible)
.find(e => /^new chat$/i.test((e.innerText || '').trim()));
if (!btn) return null;
const r = btn.getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
}
// Список сообщений виртуализован; единственный data-атрибут на странице —
// data-virtual-list-item-key. Ответ отличается от вопроса тем, что внутри
// него есть .ds-markdown.
//
// За номер ключа цепляться НЕЛЬЗЯ, и сортировать по нему тоже: номера живут
// своей жизнью — после правок список перенумеровывается, а у вопросов они
// теперь и вовсе отрицательные (замер 11.09.2026: -4, 2, -11, 8 для двух
// обменов подряд). Сортировка по возрастанию отдавала ПОСЛЕДНИЙ вопрос вместо
// первого, и правка уходила не в то сообщение. Берём порядок DOM: список
// рисуется сверху вниз, значит первый отрисованный вопрос — он и есть.
function __firstMsg() {
return [...document.querySelectorAll('[data-virtual-list-item-key]')]
.find(e => !e.querySelector('.ds-markdown')) || null;
}
// Кнопки под сообщением (копировать, редактировать). Появляются по наведению
// и прячутся, когда курсор уходит вниз к ним, поэтому жать только из DOM.
function __msgButtons() {
const it = __firstMsg();
if (!it) return [];
return [...it.querySelectorAll('[role="button"], button')].filter(__visible);
}
function __clickEdit() {
const b = __msgButtons();
if (b.length < 2) return { ok: false, reason: 'кнопок под сообщением ' + b.length };
b[1].click(); // вторая — редактирование, первая — копирование
return { ok: true };
}
// Поле правки — любая видимая textarea ВЫШЕ поля ввода (оно всегда самое нижнее).
function __editArea() {
const all = [...document.querySelectorAll('textarea')].filter(__visible)
.sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);
return all.length > 1 ? all[0] : null;
}
function __setEditText(value) {
const el = __editArea();
if (!el) return { ok: false, reason: 'поле правки не открылось' };
el.focus();
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
setter.call(el, value);
el.dispatchEvent(new Event('input', { bubbles: true }));
return { ok: true, len: el.value.length };
}
// Все чаты сайдбара. Адресоваться только по id: порядок меняется — тот,
// куда писали последним, встаёт первым, поэтому индексы протухают между шагами.
function __chatIds() {
return [...document.querySelectorAll('a[href^="/a/chat/s/"]')]
.filter(__visible)
.map(e => ({ id: e.getAttribute('href').split('/').pop(), title: (e.innerText || '').trim().slice(0, 40) }));
}
// Куда навести мышь, чтобы у чата проявились три точки.
function __chatRowBox(id) {
const a = [...document.querySelectorAll('a[href^="/a/chat/s/"]')]
.find(e => e.getAttribute('href').endsWith(id));
if (!a) return null;
a.scrollIntoView({ block: 'center' });
const r = a.getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), top: r.y, bottom: r.y + r.height };
}
// Кнопка "..." ИМЕННО этой строки: её центр по вертикали лежит внутри строки.
// Брать самую правую видимую кнопку нельзя — у активного чата точки видны
// всегда, и однажды так удалился рабочий чат вместо мусорного.
function __dotsBox(top, bottom) {
const d = [...document.querySelectorAll('[role="button"], button')]
.filter(__visible)
.map(e => ({ r: e.getBoundingClientRect() }))
.filter(o => o.r.left < 340 && o.r.width < 60 && o.r.height < 60)
.filter(o => { const cy = o.r.y + o.r.height / 2; return cy >= top && cy <= bottom; })
.sort((p, q) => q.r.x - p.r.x)[0];
return d ? { x: Math.round(d.r.x + d.r.width / 2), y: Math.round(d.r.y + d.r.height / 2) } : null;
}
function __menuItemBox(re) {
for (const e of document.querySelectorAll('div,span,li,button')) {
if (e.children.length || !__visible(e)) continue;
if (!re.test((e.innerText || '').trim())) continue;
const r = e.getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
}
return null;
}
// Кнопка подтверждения в диалоге. Подпись лежит во вложенном элементе,
// поэтому фильтр «элемент без детей» её не находит — берём самый мелкий.
function __confirmBox() {
const box = [...document.querySelectorAll('[class*="modal"],[class*="dialog"],[role="dialog"],[class*="confirm"]')]
.filter(__visible).pop();
if (!box) return null;
return [...box.querySelectorAll('*')]
.filter(e => __visible(e) && /^(delete chat|delete|удалить)$/i.test((e.innerText || '').trim()))
.map(e => { const r = e.getBoundingClientRect();
return { area: r.width * r.height, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; })
.sort((a, b) => a.area - b.area)[0] || null;
}
function __chatId() {
const m = location.href.match(/\\/chat\\/s\\/([0-9a-f-]+)/i);
return m ? m[1] : null;
}
// Кнопка отправки. Подписи и aria у неё нет, но класс-модификатор устойчив:
// ds-button--primary есть только у неё (проверено 06.08.2026 — на странице
// ровно один такой элемент). Геометрия оставлена запасным путём на случай,
// если вёрстка поедет.
function __sendButton() {
const byClass = [...document.querySelectorAll('[role="button"], button')]
.filter(__visible)
.filter(e => /\\bds-button--primary\\b/.test(e.getAttribute('class') || ''));
if (byClass.length) return byClass[byClass.length - 1];
const ta = __input();
if (!ta) return null;
const tr = ta.getBoundingClientRect();
return [...document.querySelectorAll('div[role="button"], button')]
.filter(__visible)
.filter(e => /ds-button/.test(e.getAttribute('class') || ''))
.filter(e => { const r = e.getBoundingClientRect();
return r.width < 90 && r.height < 90 && r.y > tr.y && r.x > tr.x + tr.width - 260; })
.sort((a, b) => b.getBoundingClientRect().x - a.getBoundingClientRect().x)[0] || null;
}
function __sendBox() {
const el = __sendButton();
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) };
}
// Идёт ли генерация. Пока DeepSeek печатает, та же кнопка превращается в
// «стоп» и остаётся активной, ХОТЯ поле ввода уже пустое. В покое при пустом
// поле она помечена ds-button--disabled. Отсюда признак:
// пусто + кнопка активна = печатает
// пусто + кнопка погашена = готово
// Замерено 06.08.2026: класс --disabled пропал на 3-й секунде ответа и
// вернулся на 42-й, ровно когда текст перестал расти.
function __isGenerating() {
const b = __sendButton();
if (!b) return false;
const off = /ds-button--disabled/.test(b.getAttribute('class') || '');
return __inputEmpty() && !off;
}
// Пусто ли поле ввода — единственный честный признак, что сообщение ушло.
function __inputEmpty() {
const el = __input();
if (!el) return true;
const v = el.tagName.toLowerCase() === 'textarea' ? el.value : el.innerText;
return !String(v || '').trim();
}
// В innerText попадает служебная обвязка страницы: у каждого блока кода —
// подписи кнопок "Copy" и "Download" отдельными строками, у ответов поискового
// чата — номера сносок ("5", "8" и голые дефисы между ними). Всё это ломает
// код при вставке и раздувает ответ. Чистим построчно: трогаем только строки,
// целиком состоящие из мусора, поэтому содержимое кода не страдает.
function __cleanAnswer(raw) {
const lines = String(raw || '').split('\\n');
const out = [];
for (let i = 0; i < lines.length; i++) {
const t = lines[i].trim();
if (/^(Copy|Download|Копировать|Скачать)$/i.test(t)) continue;
// Сноски поискового ответа: номера источников и дефисы-разделители едут
// отдельными строками («6», «12», «-») и рвут текст на середине фразы.
// Убираем только строку ЦЕЛИКОМ из номера или дефиса — числа внутри
// предложений и нумерованные списки («1.») это не задевает.
if (/^\\d{1,3}$/.test(t) || /^[-–—.]$/.test(t)) {
const prev = (out[out.length - 1] || '').trim();
const next = (lines[i + 1] || '').trim();
// Строка-одиночка среди текста — сноска. Если вокруг пусто, значит это
// самостоятельная строка ответа, её не трогаем.
if (prev || next) continue;
}
out.push(lines[i]);
}
return out.join('\\n').replace(/\\n{3,}/g, '\\n\\n').trim();
}
function __lastAnswer() {
const { nodes } = __answerNodes();
if (!nodes.length) return null;
return __cleanAnswer(nodes[nodes.length - 1].innerText);
}
function __answerCount() { return __answerNodes().nodes.length; }
// Кнопка Continue под ответом: DeepSeek обрывает длинный ответ и предлагает
// дописать. Текст продолжения уходит В ТОТ ЖЕ блок ответа (проверено
// 11.08.2026: 32 073 → 46 422 символов, число блоков не изменилось), поэтому
// склеивать ничего не нужно — достаточно нажать и ждать дальше.
// Искать только среди КНОПОК. Поиск по тексту где угодно ловит ключевое слово
// continue внутри блока кода: подсветка оборачивает его в отдельный span, и
// текст такого span равен ровно "continue". Проверено 11.08.2026 — мост
// десять раз подряд кликал по слову в коде вместо кнопки и упирался в предел.
// Ответ с кодом на JavaScript почти всегда содержит такое слово, так что это
// не редкий случай, а обычный.
// Селектор строго '.ds-button': маска [class*="ds-button"] цепляет вложенный
// ds-button__content, и клик уходит мимо — обработчик висит на самой кнопке.
// Кнопка Retry: DeepSeek иногда отвечает «Server is busy. Try again later, or
// use Instant Mode.» вместо ответа и рисует круглую оранжевую кнопку ↻ слева
// от сообщения. Подписи у неё нет, зато модификатор ds-button--warning на
// странице только у неё (проверено 12.08.2026). Ждать в такой ситуации
// бессмысленно: генерация не идёт, и мост честно упирался в таймаут.
function __retryBox() {
const busy = /server is busy|сервер занят|try again later/i.test(document.body.innerText || '');
const btn = [...document.querySelectorAll('[role="button"], button')]
.filter(__visible)
.find(e => /\\bds-button--warning\\b/.test(e.getAttribute('class') || ''));
if (!btn || !busy) return null;
const r = btn.getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
}
function __continueBox() {
const btn = [...document.querySelectorAll('.ds-button')]
.filter(__visible)
.filter(e => !e.closest('.ds-markdown')) // не из текста ответа
.find(e => /^(continue|продолжить)$/i.test((e.innerText || '').trim()));
if (!btn) return null;
btn.scrollIntoView({ block: 'center' });
const r = btn.getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
}
`;
// Вставка текста в React-поле: нативный сеттер + событие input,
// иначе React не заметит изменения и отправит пустое сообщение.
const SET_TEXT = (text) => `(() => { ${PAGE_HELPERS}
const el = __input();
if (!el) return { ok: false, reason: 'поле ввода не найдено' };
const value = ${JSON.stringify(text)};
el.focus();
if (el.tagName.toLowerCase() === 'textarea') {
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
setter.call(el, value);
el.dispatchEvent(new Event('input', { bubbles: true }));
} else {
el.textContent = value;
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: value }));
}
// textBefore снимается ДО отправки: по нему waitForAnswer поймёт, что ответ
// сменился, даже если новый успел напечататься раньше первого замера.
return { ok: true, tag: el.tagName.toLowerCase(),
before: __answerCount(), textBefore: __lastAnswer() };
})()`;
async function pressEnter(s) {
const base = { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 };
await s.send('Input.dispatchKeyEvent', { ...base, text: '\r' });
await s.send('Input.dispatchKeyEvent', { ...base, type: 'keyUp' });
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function mouseClickAt(s, box) {
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' });
}
// Enter срабатывает не всегда: текст остаётся висеть в поле, а мы уходим ждать
// ответ, которого не будет. Поэтому отправку ПОДТВЕРЖДАЕМ — поле должно опустеть,
// иначе жмём саму кнопку настоящим мышиным кликом.
async function sendAndConfirm(s, log) {
const empty = () => s.eval(`(() => { ${PAGE_HELPERS} return __inputEmpty(); })()`);
const ways = [
async () => { await pressEnter(s); return 'Enter'; },
// DOM-вызов click() на самой кнопке. Оказался надёжнее всего: срабатывает
// и тогда, когда Enter и настоящий мышиный клик молчат — например, если
// окно браузера свёрнуто или неактивно и события ввода до страницы не
// доходят. Проверено 06.08.2026: поле опустело за 500 мс после того, как
// оба других способа подряд не сработали.
async () => {
const r = await s.eval(`(() => { ${PAGE_HELPERS}
const b = __sendButton(); if (!b) return false; b.click(); return true; })()`);
return r ? 'DOM-клик по кнопке отправки' : null;
},
async () => {
const box = await s.eval(`(() => { ${PAGE_HELPERS} return __sendBox(); })()`);
if (!box) return null;
await mouseClickAt(s, box);
return 'мышиный клик по кнопке отправки';
},
async () => { await pressEnter(s); return 'Enter (повтор)'; },
];
for (const way of ways) {
const how = await way();
if (!how) continue;
for (let i = 0; i < 12; i++) { // до ~3 с на опустошение поля
await sleep(250);
if (await empty()) { log(`отправлено: ${how}`); return; }
}
log(`${how} не сработал — поле всё ещё занято, пробую иначе`);
}
throw new Error(
'Сообщение не ушло: текст остался в поле ввода.\n' +
'Посмотрите вкладку — возможна капча, лимит сообщений или требуется вход.');
}
// Задать вопрос ПРАВКОЙ первого сообщения вместо отправки нового.
// Чат остаётся из одного обмена: контекст не растёт, мусор не копится,
// приставка из seed-prompt.md уезжает вместе с каждым вопросом.
async function editFirstAndSend(s, text, log) {
const box = await s.eval(`(() => { ${PAGE_HELPERS}
const it = __firstMsg();
if (!it) return null;
it.scrollIntoView({ block: 'center' });
const r = it.getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
})()`);
if (!box) throw new Error('первое сообщение чата не найдено (нет [data-virtual-list-item-key="1"])');
// наведение обязательно: без него кнопок под сообщением в DOM нет
await s.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: box.x, y: box.y, button: 'none', clickCount: 0 });
await sleep(700);
const ed = await s.eval(`(() => { ${PAGE_HELPERS} return __clickEdit(); })()`);
if (!ed.ok) throw new Error('не удалось открыть правку: ' + ed.reason);
await sleep(900);
const set = await s.eval(`(() => { ${PAGE_HELPERS} return __setEditText(${JSON.stringify(text)}); })()`);
if (!set.ok) throw new Error(set.reason);
log(`правка первого сообщения: ${set.len} символов`);
await sleep(400);
const state = await s.eval(`(() => { ${PAGE_HELPERS}
return { before: __answerCount(), textBefore: __lastAnswer() }; })()`);
await pressEnter(s);
for (let i = 0; i < 16; i++) {
await sleep(300);
const gone = await s.eval(`(() => { ${PAGE_HELPERS} return !__editArea(); })()`);
if (gone) { log('отправлено правкой'); return state; }
}
throw new Error('правка не отправилась: поле правки всё ещё открыто');
}
// Открыть чат по id из chats.json — переходом по адресу, а не кликом в сайдбаре:
// названия чатов DeepSeek меняет сам, а id из URL постоянен.
async function openChatById(s, id, log) {
const cur = await s.eval(`(() => { ${PAGE_HELPERS} return __chatId(); })()`);
if (cur === id) { log(`чат: уже открыт (${id})`); return; }
await s.send('Page.enable').catch(() => { });
await s.send('Page.navigate', { url: `https://${CHAT_HOST}/a/chat/s/${id}` });
for (let i = 0; i < 40; i++) { // до ~20 с на загрузку SPA
await sleep(500);
const ok = await s.eval(`(() => { ${PAGE_HELPERS}
return __chatId() === ${JSON.stringify(id)} && !!__input(); })()`).catch(() => false);
if (ok) { await sleep(800); log(`чат: ${id}`); return; }
}
throw new Error(`не удалось открыть чат ${id} — страница не загрузилась.`);
}
// Щёлкнуть по тумблеру настоящим мышиным событием. DOM-вызов click() тут
// тоже срабатывает, но мышь проверена на большем числе случаев и работает
// даже там, где React глотает синтетический клик.
async function clickToggle(s, name) {
const box = await s.eval(`(() => { ${PAGE_HELPERS}
const el = [...document.querySelectorAll('.ds-toggle-button')]
.filter(__visible)
.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;
await hoverClick(s, box);
await sleep(600);
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;
}
log(await isOn() ? 'DeepThink выключить не удалось' : 'DeepThink выключен');
}
// Ответ считается готовым, когда его текст перестал меняться.
// Ответ считается готовым, только когда страница ПЕРЕСТАЛА печатать.
// Раньше признаком был «текст не менялся 2,5 с», и это врало: в режиме expert
// DeepSeek думает паузами, любая заминка длиннее порога выглядела как конец.
// Замерено 06.08.2026 на одном ответе: наружу ушло 640 символов, тогда как
// ответ дорос до 4135 — терялось шесть седьмых текста вместе со всем кодом,
// причём молча. Теперь ждём, пока __isGenerating() не станет false.
// stallSec — НЕ общий срок на ответ, а максимум БЕЗДЕЙСТВИЯ. Пока страница
// печатает или текст растёт, ждём сколько угодно: длинный ответ на 13 КБ
// дописывался 240 с и старый общий таймаут рубил его на живом месте. Срок
// отсчитывается от последнего признака жизни, поэтому срабатывает только
// там, где и должен — капча, разлогин, зависшая вкладка, пустой экран.
// Ответ дописан — но в DOM его ещё нет целиком. Блоки кода DeepSeek рисует
// лениво, а браузер работает В ФОНЕ: окно неактивно, страница не
// отрисовывается, и содержимое блоков в DOM просто не создаётся. От него
// остаются первые две строки-заглушки, и никакой textContent остального не
// достанет — данных нет.
//
// Прокрутка не помогает (проверено: проход по всему ответу шагами не дал ни
// одного лишнего символа). Помогает единственное — заставить браузер
// отрисовать страницу, а через CDP это делает Page.captureScreenshot.
// Замерено 11.08.2026 на одном ответе: 3136 символов до съёмки, 30 817 после.
// Картинка не нужна и выбрасывается, поэтому берём самую дешёвую — jpeg
// с качеством 1.
async function revealAnswer(s, current, log) {
let best = current || '';
for (let i = 0; i < 3; i++) {
try { await s.send('Page.captureScreenshot', { format: 'jpeg', quality: 1 }); }
catch { break; } // отрисовка недоступна — отдаём что есть
await sleep(350);
const full = await s.eval(`(() => { ${PAGE_HELPERS} return __lastAnswer(); })()`);
if (!full || full.length <= best.length) break; // больше не растёт
if (log) log(`после отрисовки ответ вырос: ${best.length}${full.length} символов`);
best = full;
}
return best;
}
async function waitForAnswer(s, countBefore, stallSec, log, textBefore = null) {
const STABLE_MS = 2500;
const MAX_CONTINUE = 10; // предел на случай, если кнопка не гаснет
const MAX_RETRY = 5; // «Server is busy» — сколько раз пробовать снова
let continues = 0;
let retries = 0;
let lastText = null;
// Что было на экране ДО отправки. Раньше это снималось при первом замере, уже
// после — и быстрый ответ (меньше 600 мс) выглядел как «ничего не изменилось»:
// grew не взводился, и команда честно ждала весь таймаут, приписывая
// «замерло», хотя ответ давно лежал на экране. Поймано на «столица
// Португалии»: ответ пришёл мгновенно, ожидание длилось 240 с.
let firstText = textBefore;
let lastChange = Date.now(); // когда текст в последний раз менялся
let lastProgress = Date.now(); // когда были ЛЮБЫЕ признаки жизни
let grew = false;
for (;;) {
await sleep(600);
const state = await s.eval(`(() => { ${PAGE_HELPERS}
return { count: __answerCount(), text: __lastAnswer(), generating: __isGenerating() }; })()`);
const text = state.text ?? '';
if (firstText === null) firstText = text;
// При ПРАВКЕ первого сообщения число ответов не растёт: старый заменяется
// новым. Поэтому признаком «ответ пошёл» служит любой из трёх: появился
// новый блок, началась генерация, текст отличается от исходного.
if (state.count > countBefore || state.generating || text !== firstText) grew = true;
if (text !== lastText) {
lastText = text; lastChange = Date.now(); lastProgress = Date.now();
continue;
}
if (state.generating) { // пауза в размышлении — это не конец
lastChange = Date.now(); lastProgress = Date.now();
continue;
}
// «Server is busy» — ответа не будет вовсе, ждать нечего. Жмём Retry и
// ждём заново. Проверяем ДО условия стабильности: текста может не быть
// совсем, и тогда мост просто досиживал бы до таймаута.
if (!state.generating && retries < MAX_RETRY) {
const retry = await s.eval(`(() => { ${PAGE_HELPERS} return __retryBox(); })()`);
if (retry) {
retries++;
if (log) log(`сервер занят — повторяю (${retries}/${MAX_RETRY})`);
await sleep(3000); // сразу долбить занятый сервер смысла нет
await hoverClick(s, retry);
await sleep(2000);
lastChange = Date.now(); lastProgress = Date.now();
continue;
}
}
if (grew && text && Date.now() - lastChange >= STABLE_MS) {
// Печатать перестали — но это может быть не конец ответа, а обрыв по
// лимиту с предложением дописать. Пока кнопка есть, жмём её и ждём
// дальше: продолжение уходит в тот же блок, склейка не нужна.
const box = continues < MAX_CONTINUE
? await s.eval(`(() => { ${PAGE_HELPERS} return __continueBox(); })()`)
: null;
if (box) {
await sleep(400); // дать прокрутке доехать
await hoverClick(s, box);
continues++;
if (log) log(`нажата кнопка Continue (${continues}) — ответ был оборван`);
await sleep(1500);
lastChange = Date.now(); lastProgress = Date.now();
continue;
}
return revealAnswer(s, text, log);
}
if (Date.now() - lastProgress >= stallSec * 1000) {
// И на аварийном выходе текст снимаем после прокрутки: иначе к «замерло»
// добавится ещё и потеря блоков кода, а причину потом не разделить.
if (lastText) return (await revealAnswer(s, lastText, log))
+ `\n\n[замерло: ${stallSec} с без признаков генерации — ответ может быть неполным]`;
throw new Error(
`Ответ не появился, страница ${stallSec} с не подаёт признаков жизни.\n` +
'Проверьте вкладку: возможна капча, лимит сообщений или требуется вход.');
}
}
}
// Навести мышь и щёлкнуть настоящим мышиным событием.
async function hoverClick(s, box) {
await s.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: box.x, y: box.y, button: 'none', clickCount: 0 });
await sleep(250);
const p = { x: box.x, y: box.y, button: 'left', clickCount: 1 };
await s.send('Input.dispatchMouseEvent', { ...p, type: 'mousePressed' });
await s.send('Input.dispatchMouseEvent', { ...p, type: 'mouseReleased' });
}
// Новый чат. Единственное место, где это делается: раньше клик был внутри
// помощника страницы и его беззвучный отказ никто не замечал. Теперь ждём
// подтверждения адресом — из старого чата мы обязаны уйти.
async function clickNewChat(s, log = () => {}) {
await pressEscape(s);
await repaint(s);
const was = await s.eval(`(() => { ${PAGE_HELPERS} return __chatId(); })()`);
const box = await s.eval(`(() => { ${PAGE_HELPERS} return __newChatBox(); })()`);
if (!box) throw new Error('кнопка New chat не найдена');
await hoverClick(s, box);
// Пустой чат адреса не имеет: __chatId() становится null. Ждём именно смены.
for (let i = 0; i < 20; i++) {
await sleep(300);
const now = await s.eval(`(() => { ${PAGE_HELPERS} return __chatId(); })()`);
if (now !== was) { log('открыт новый чат'); return; }
}
throw new Error('нажал New chat, но чат не сменился — вкладка не откликнулась');
}
// Заставить браузер перерисовать страницу. У окна в фоне рисование стоит, и
// то, что появляется по :hover — кнопка «…» у строки чата, пункты меню — в DOM
// просто не создаётся. Через CDP единственный способ вынудить отрисовку —
// снять кадр; сама картинка не нужна, поэтому берём самую дешёвую.
// Тот же корень, что у пропадавших блоков кода (см. revealAnswer).
async function repaint(s) {
try { await s.send('Page.captureScreenshot', { format: 'jpeg', quality: 1 }); }
catch { /* не вышло — работаем как есть */ }
}
// Закрыть открытое меню: иначе следующий клик попадёт в него, а не в строку.
async function pressEscape(s) {
for (const type of ['keyDown', 'keyUp'])
await s.send('Input.dispatchKeyEvent', { type, key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27, nativeVirtualKeyCode: 27 });
await sleep(350);
}
// Удалить ОДИН чат по id. Порядок шагов проверен на 12 чатах подряд: навести
// мышь на строку (без этого у неактивного чата точек в DOM нет), щёлкнуть
// "...", выбрать Delete, подтвердить "Delete chat". Адресуемся строго по id:
// порядок чатов в сайдбаре меняется, а у активного чата кнопка видна всегда —
// на этом уже удалялся не тот чат.
async function deleteChat(s, id, log = () => {}) {
await pressEscape(s);
await repaint(s);
const row = await s.eval(`(() => { ${PAGE_HELPERS} return __chatRowBox(${JSON.stringify(id)}); })()`);
if (!row) return true; // уже нет — считаем удалённым
await s.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: row.x, y: row.y, button: 'none', clickCount: 0 });
await sleep(450);
await repaint(s); // без этого кнопки «…» в DOM нет
const dots = await s.eval(`(() => { ${PAGE_HELPERS} return __dotsBox(${row.top}, ${row.bottom}); })()`);
if (!dots) return false;
await hoverClick(s, dots);
await sleep(600);
await repaint(s);
const del = await s.eval(`(() => { ${PAGE_HELPERS} return __menuItemBox(/^(delete|удалить)$/i); })()`);
if (!del) return false;
await hoverClick(s, del);
await sleep(800);
await repaint(s);
const yes = await s.eval(`(() => { ${PAGE_HELPERS} return __confirmBox(); })()`);
if (yes) { await hoverClick(s, yes); await sleep(1000); }
const gone = await s.eval(`(() => { ${PAGE_HELPERS}
return !__chatIds().some(c => c.id === ${JSON.stringify(id)}); })()`);
if (gone) log(`удалён чат ${id}`);
return gone;
}
// Убрать из сайдбара всё, чего нет в chats.json. Вкладка DeepSeek — рабочее
// место моста, а не хранилище переписки: отработавшие чаты копились без счёта
// (за сутки работы Эмилии их набралось семнадцать) и мешали искать нужный.
// Стоит недорого: список чатов — один eval, и если лишнего нет, дальше ничего
// не происходит.
async function pruneChats(s, log = () => {}) {
const keep = await savedChatId();
if (!keep) return 0; // не знаем, что беречь, — не трогаем ничего
const all = await s.eval(`(() => { ${PAGE_HELPERS} return __chatIds(); })()`);
const extra = all.filter(c => c.id !== keep);
if (!extra.length) return 0;
log(`лишних чатов в сайдбаре: ${extra.length}, убираю`);
let done = 0;
for (const c of extra) {
try { if (await deleteChat(s, c.id)) done++; }
catch (e) { log(`не удалось удалить «${c.title}»: ${e.message}`); }
}
log(`удалено чатов: ${done}`);
return done;
}
// DeepSeek разрешает править первое сообщение лишь ШЕСТЬ раз, дальше кнопка
// правки перестаёт открывать поле. Поэтому чат — расходник: как только правки
// кончились, заводим новый и переписываем chats.json.
//
// Чат сразу открывается настоящим вопросом. Раньше он открывался служебным
// приветствием, а вопрос уходил ПРАВКОЙ этой фразы: круг DeepSeek тратился
// впустую и одна правка из шести — тоже.
async function makeChat(s, log, firstText, timeout = 300, imageBytes = null) {
await clickNewChat(s, log);
await sleep(1500);
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, timeout, log, put.textBefore);
// Ждём id, отличный от прежнего. Просто «непустой» тут мало: если переход
// не состоялся, в адресе остаётся старый id, и он выглядит как новый.
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 !== old) break;
id = null;
await sleep(500);
}
if (!id) throw new Error('новый чат создан, но его 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 };
}
// ==================================================================
// Публичный вход
// ==================================================================
// Флаги окна не украшение. Замер в DeepShim 11.08.2026: у неактивного окна
// Windows сообщает браузеру, что оно перекрыто, рисование прекращается, а
// DeepSeek рисует блоки кода лениво — ответ приезжает без кода (3136 символов
// вместо 30 817). Эти флаги запрещают браузеру засыпать.
const BROWSER_FLAGS = [
'--remote-allow-origins=*',
'--start-maximized',
'--disable-backgrounding-occluded-windows',
'--disable-renderer-backgrounding',
'--disable-background-timer-throttling',
'--disable-features=CalculateNativeWinOcclusion',
];
export async function browserAlive() {
try {
const r = await fetch(`http://127.0.0.1:${PORT}/json/version`,
{ signal: AbortSignal.timeout(3000) });
return r.ok;
} catch { return false; }
}
// Поднять браузер, если он не отвечает, и открыть вкладку с чатом, если её нет.
export async function ensureBrowser(log = () => {}) {
if (!(await browserAlive())) {
if (!BROWSER.exe || !BROWSER.profileDir) {
throw new Error(
`Браузер не отвечает на порту ${PORT}, и запустить его сам не могу: ` +
'в config.json не задан browserExe или profileDir.');
}
if (!existsSync(BROWSER.exe)) {
throw new Error(`Браузер не найден: ${BROWSER.exe}. Проверьте browserExe в config.json.`);
}
log('Браузер не отвечает, поднимаю...');
spawn(BROWSER.exe,
[`--user-data-dir=${BROWSER.profileDir}`,
`--remote-debugging-port=${PORT}`,
...BROWSER_FLAGS, BROWSER.url],
{ detached: true, stdio: 'ignore' }).unref();
// Холодный профиль поднимается неспешно; 60 секунд с запасом.
for (let i = 0; i < 30 && !(await browserAlive()); i++) await sleep(2000);
if (!(await browserAlive())) {
throw new Error(
`Браузер так и не открыл отладочный порт ${PORT}. Скорее всего этот ` +
'профиль уже открыт БЕЗ отладочного порта: новый процесс отдаёт ссылку ' +
`старому окну и завершается. Закройте окно с профилем ${BROWSER.profileDir} ` +
'и повторите.');
}
log('браузер поднялся');
}
const tabs = () => fetch(`http://127.0.0.1:${PORT}/json/list`)
.then(r => r.json()).catch(() => []);
const isChat = t => String(t.url || '').includes('deepseek');
if ((await tabs()).some(isChat)) return waitPageReady(log);
// Новая вкладка в свежих сборках Chromium открывается ТОЛЬКО методом PUT:
// на GET протокол отвечает 405 и молча ничего не делает.
//
// И адрес НЕ параметр запроса: всё, что стоит после '?', протокол берёт
// как сам адрес целиком. С '?url=...' открывалась пустая about:blank —
// строку 'url=https%3A%2F%2F...' он честно считал адресом.
const r = await fetch(`http://127.0.0.1:${PORT}/json/new?${BROWSER.url}`,
{ method: 'PUT' });
if (!r.ok) throw new Error(`Не удалось открыть вкладку DeepSeek: ${r.status}.`);
await sleep(1000);
// Открыл — убедись, что открылось именно то: молчаливый промах здесь уже был.
if (!(await tabs()).some(isChat)) {
throw new Error(`Вкладка открылась, но не с ${BROWSER.url}. `
+ 'Похоже, браузер не принял адрес — закройте лишние вкладки и повторите.');
}
log('вкладка с чатом открыта');
await waitPageReady(log);
}
// Открытая вкладка — ещё не готовый чат. У холодного браузера адрес задан
// аргументом командной строки, вкладка в списке появляется сразу, а разметки
// нет ещё несколько секунд: повтор после смерти браузера падал на «кнопка
// New chat не найдена». Ждём поле ввода — единственную примету, что страницей
// можно пользоваться.
async function waitPageReady(log = () => {}) {
let s;
try { s = await Session.connect((await findChatTab()).webSocketDebuggerUrl); }
catch { return; } // вкладку успели закрыть — разберётся вызвавший
try {
for (let i = 0; i < 60; i++) { // до ~60 с: холодный профиль неспешен
const st = await s.eval(`(() => { ${PAGE_HELPERS}
return { input: !!__input(),
login: /log ?in|войти|sign in/i.test(document.body.innerText.slice(0, 3000)) };
})()`).catch(() => null);
if (st && st.input) { await sleep(800); return; }
// Вход руками — ждать нечего: страница жива, просто мы не залогинены.
if (st && st.login) { log('нужен вход в DeepSeek — сделайте это в окне браузера'); return; }
await sleep(1000);
}
throw new Error('Вкладка DeepSeek открыта, но чат так и не появился: '
+ 'страница не загрузилась или изменилась разметка.');
} finally { s.close(); }
}
// Приметы того, что вкладка или браузер пропали посреди работы. По ним ask()
// решает, что дело не в вопросе, и стоит восстановиться и повторить один раз.
function isLostTab(e) {
return /вкладка DeepSeek закрылась|вкладка не ответила на|вкладка не отозвалась|WebSocket|Вкладка chat\.deepseek\.com не открыта|Браузер с отладочным портом/i
.test(String(e && e.message || e));
}
// Поднять браузер и вкладку, ничего не спрашивая. Отдаёт состояние — им живёт
// сторож и по нему рисуются индикаторы.
export async function revive(log = () => {}) {
try {
await ensureBrowser(log);
return { ok: true, browser: true, tab: true };
} catch (e) {
return { ok: false, browser: await browserAlive(), tab: false, error: e.message };
}
}
// Картинка приезжает к нам в памяти, а странице нужен файл на диске: другого
// способа наполнить <input type="file"> у протокола нет.
async function attachImage(s, bytes, log) {
const { mkdtemp, writeFile: wf, rm } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
const dir = await mkdtemp(join(tmpdir(), 'dsapi-img-'));
const file = resolvePath(join(dir, 'image.jpg'));
await wf(file, bytes);
try {
await s.send('DOM.enable');
const { root } = await s.send('DOM.getDocument');
const { nodeId } = await s.send('DOM.querySelector',
{ nodeId: root.nodeId, selector: 'input[type="file"]' });
if (!nodeId) throw new Error('поле загрузки файла на странице не найдено');
await s.send('DOM.setFileInputFiles', { files: [file], nodeId });
log('приложена картинка');
await sleep(2500); // дать чату загрузить вложение
} finally {
// Файл нужен только на время загрузки; страница читает его сразу.
setTimeout(() => rm(dir, { recursive: true, force: true }).catch(() => {}), 30000);
}
}
// Один вопрос — один ответ. Всё остальное (какой чат, жив ли он, не кончились
// ли правки) решается здесь и наружу не торчит.
export async function ask({ prompt, imageBytes = null,
timeout = 300, log = () => {} }) {
if (!String(prompt || '').trim()) throw new Error('пустой вопрос');
await ensureBrowser(log);
try {
return await askOnce({ prompt, imageBytes, timeout, log });
} catch (e) {
if (!isLostTab(e)) throw e;
// Вкладку закрыли или браузер уронили посреди работы. Поднимаем заново
// и спрашиваем ещё раз — ровно один.
//
// Повтор безопасен именно из-за правки первого сообщения: вопрос не
// добавляется к чату, а заменяет его содержимое. Даже если первая попытка
// успела отправить вопрос, вторая его перепишет, а не задаст дважды.
log(`связь с вкладкой потеряна (${e.message}); поднимаю и повторяю`);
await ensureBrowser(log);
return askOnce({ prompt, imageBytes, timeout, log });
}
}
async function askOnce({ prompt, imageBytes, timeout, log }) {
return withChat(async (s) => {
await pruneChats(s, log); // сайдбар держим чистым
const known = await savedChatId();
const alive = known && await s.eval(`(() => { ${PAGE_HELPERS}
return __chatIds().some(c => c.id === ${JSON.stringify(String(known))}); })()`);
// Картинка — всегда НОВЫЙ чат. Вложение цепляется к полю ввода, а правка
// идёт в отдельном поле над ним: в старый чат картинку не перенести, и
// отправка новым сообщением ломала единственный уговор, на котором всё
// держится, — «в чате ровно один обмен». Поймано 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);
await ensureNoDeepThink(s, log);
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;
}
});
}
// Диагностика: что видно во вкладке. Используется эндпойнтом /api/probe.
export async function probe() {
await ensureBrowser();
return withChat(async (s, tab) => {
const info = await s.eval(`(() => { ${PAGE_HELPERS}
const el = __input();
const a = __answerNodes();
return {
input: !!el,
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, workChat: await savedChatId() };
});
}