Одна модель вместо трёх: 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
+101
-257
@@ -29,14 +29,14 @@ export function configure({ cdpPort, browserExe, profileDir, chatUrl }) {
|
||||
if (chatUrl) BROWSER.url = chatUrl;
|
||||
}
|
||||
|
||||
// Роли — это отдельные чаты под разные режимы. Режим выбирается при СОЗДАНИИ
|
||||
// чата и потом не меняется: тумблеры исчезают после первого сообщения.
|
||||
export const ROLES = ['think', 'search', 'vision'];
|
||||
// Модель у DeepSeek теперь одна и универсальная: выбор из трёх (Instant /
|
||||
// Expert / Vision) убран из веб-морды, картинки разбирает та же модель.
|
||||
// Поэтому и чат нужен один — рабочий, расходный.
|
||||
|
||||
// Роль → id чата из chats.json. Файл — рабочее состояние, а не настройка:
|
||||
// он заводится сам и переписывается при каждой смене чата.
|
||||
async function chatsMap() {
|
||||
try { return JSON.parse(await readFile(CHATS_FILE, 'utf8')); } catch { return {}; }
|
||||
// Id рабочего чата из chats.json. Файл — состояние, а не настройка: он
|
||||
// заводится сам и переписывается, когда чат меняется.
|
||||
async function savedChatId() {
|
||||
try { return JSON.parse(await readFile(CHATS_FILE, 'utf8')).id || null; } catch { return null; }
|
||||
}
|
||||
|
||||
|
||||
@@ -181,96 +181,21 @@ function __answerNodes() {
|
||||
}
|
||||
return { sel: null, nodes: [] };
|
||||
}
|
||||
// Тумблеры DeepThink и Search рядом с полем ввода. Раньше они пропадали, как
|
||||
// только в чате появлялось первое сообщение, — на этом держалась вся прежняя
|
||||
// схема с отдельным чатом под каждый режим. Проверено 11.09.2026: теперь они
|
||||
// видны и переключаются в любом чате, в том числе начатом.
|
||||
//
|
||||
// Разметка: div.ds-toggle-button с aria-pressed. Вложенная иконка несёт класс
|
||||
// ds-toggle-button__icon — это не тумблер, кликать по ней бессмысленно.
|
||||
function __toggles() {
|
||||
// В диалоге подписи пропадают и остаются иконки, поэтому опора на класс тумблера,
|
||||
// а текст/подсказка — лишь для имени.
|
||||
return [...document.querySelectorAll('div[role="button"], button, [class*="toggle-button"]')]
|
||||
return [...document.querySelectorAll('.ds-toggle-button')]
|
||||
.filter(__visible)
|
||||
.filter(e => {
|
||||
const c = e.getAttribute('class') || '';
|
||||
if (/toggle-button__/.test(c)) return false; // вложенные иконки, не сами тумблеры
|
||||
return /ds-toggle-button/.test(c) || /^(DeepThink|Search)$/i.test((e.innerText || '').trim());
|
||||
})
|
||||
.map(e => {
|
||||
const cls = e.getAttribute('class') || '';
|
||||
return {
|
||||
name: (e.innerText || '').trim() ||
|
||||
e.getAttribute('aria-label') || e.getAttribute('title') ||
|
||||
(e.querySelector('[aria-label]')?.getAttribute('aria-label')) || '(без подписи)',
|
||||
// активный тумблер помечается доп. классом либо aria-атрибутом
|
||||
on: /active|checked|selected|--on\\b/i.test(cls) ||
|
||||
e.getAttribute('aria-pressed') === 'true' ||
|
||||
e.getAttribute('aria-checked') === 'true',
|
||||
cls
|
||||
};
|
||||
});
|
||||
}
|
||||
function __clickToggle(name) {
|
||||
// Ищем среди тех же элементов, что показывает __toggles: тумблер — это div
|
||||
// с классом ds-toggle-button, а не button с role.
|
||||
const el = [...document.querySelectorAll('div[role="button"], button, [class*="toggle-button"]')]
|
||||
.filter(__visible)
|
||||
.filter(e => !/toggle-button__/.test(e.getAttribute('class') || ''))
|
||||
.find(e => (e.innerText || '').trim().toLowerCase() === name.toLowerCase());
|
||||
if (!el) return { ok: false, reason: 'кнопка ' + name + ' не найдена' };
|
||||
el.click();
|
||||
return { ok: true };
|
||||
}
|
||||
// Режим (Search / DeepThink) задаётся при СОЗДАНИИ чата и потом не меняется:
|
||||
// тумблеры пропадают, как только в чате появилось первое сообщение. Поэтому
|
||||
// работа идёт через переключение между заранее созданными чатами в сайдбаре.
|
||||
function __chatItems() {
|
||||
const byHref = [...document.querySelectorAll('a[href*="/chat/s/"]')].filter(__visible);
|
||||
if (byHref.length) {
|
||||
return { how: 'ссылки', items: byHref.map(e => ({
|
||||
title: (e.innerText || '').replace(/\\s+/g, ' ').trim().slice(0, 80),
|
||||
href: e.getAttribute('href'),
|
||||
current: location.href.includes((e.getAttribute('href') || '').split('/').pop())
|
||||
})) };
|
||||
}
|
||||
// Сайдбар без <a>: берём кликабельные строки с текстом из левой колонки.
|
||||
const rows = [...document.querySelectorAll('div[class*="chat"], div[class*="history"], li')]
|
||||
.filter(__visible)
|
||||
.filter(e => e.getBoundingClientRect().left < 320 && e.getBoundingClientRect().width < 340)
|
||||
.filter(e => { const t = (e.innerText || '').trim(); return t && t.length < 120; });
|
||||
const uniq = rows.filter(e => !rows.some(o => o !== e && o.contains(e)));
|
||||
return { how: 'сайдбар', items: uniq.map(e => ({
|
||||
title: (e.innerText || '').replace(/\\s+/g, ' ').trim().slice(0, 80), href: null, current: false
|
||||
})) };
|
||||
}
|
||||
function __openChat(needle) {
|
||||
const low = String(needle).toLowerCase();
|
||||
const cand = [...document.querySelectorAll('a[href*="/chat/s/"]')].filter(__visible)
|
||||
.find(e => (e.innerText || '').toLowerCase().includes(low) ||
|
||||
(e.getAttribute('href') || '').toLowerCase().includes(low));
|
||||
if (cand) { cand.click(); return { ok: true, title: (cand.innerText || '').trim() }; }
|
||||
const rows = __chatItems().items;
|
||||
const idx = rows.findIndex(r => r.title.toLowerCase().includes(low));
|
||||
if (idx === -1) return { ok: false, reason: 'чат не найден: ' + needle };
|
||||
const all = [...document.querySelectorAll('div[class*="chat"], div[class*="history"], li')]
|
||||
.filter(__visible).filter(e => (e.innerText || '').replace(/\\s+/g, ' ').trim().slice(0, 80) === rows[idx].title);
|
||||
if (!all.length) return { ok: false, reason: 'не удалось кликнуть по чату' };
|
||||
all[0].click();
|
||||
return { ok: true, title: rows[idx].title };
|
||||
}
|
||||
// Индикатор выбранной модели рядом с полем ввода: Instant / DeepThink / Search.
|
||||
// Это единственный надёжный признак — классы тумблеров врут, а подпись показывает,
|
||||
// чем чат будет отвечать на самом деле.
|
||||
function __modelLabel() {
|
||||
// Выбор модели — это radiogroup: div[data-model-type=default|expert|vision]
|
||||
// с aria-checked. Это точный источник, подписи и классы вторичны.
|
||||
const radios = [...document.querySelectorAll('[data-model-type][role="radio"]')];
|
||||
if (radios.length) {
|
||||
const on = radios.find(r => r.getAttribute('aria-checked') === 'true');
|
||||
return on ? [on.getAttribute('data-model-type')] : [];
|
||||
}
|
||||
// Чат уже начат: radiogroup спрятан, остаётся только подпись рядом с полем ввода.
|
||||
const known = /^(instant|expert|vision)$/i;
|
||||
return [...document.querySelectorAll('span, div')]
|
||||
.filter(__visible)
|
||||
.filter(e => !e.children.length && known.test((e.innerText || '').trim()))
|
||||
.filter(e => !e.closest('[class*="ds-toggle-button"]'))
|
||||
.map(e => (e.innerText || '').trim().toLowerCase());
|
||||
.filter(e => !/ds-toggle-button__/.test(e.getAttribute('class') || ''))
|
||||
.map(e => ({
|
||||
name: (e.innerText || '').trim() || '(без подписи)',
|
||||
on: e.getAttribute('aria-pressed') === 'true',
|
||||
}));
|
||||
}
|
||||
// Только координаты: сам клик — настоящими мышиными событиями снаружи.
|
||||
// Причина найдена замером 26.08.2026: btn.click() из DOM React глотает молча,
|
||||
@@ -288,17 +213,15 @@ function __newChatBox() {
|
||||
// data-virtual-list-item-key. Ответ отличается от вопроса тем, что внутри
|
||||
// него есть .ds-markdown.
|
||||
//
|
||||
// За номер ключа цепляться НЕЛЬЗЯ: после каждой правки список
|
||||
// перенумеровывается (было 1/2, стало 3/4), а невидимых элементов в DOM
|
||||
// вообще нет — виртуализация их выбрасывает. Берём самый ранний из
|
||||
// отрисованных вопросов. После правки в чате остаётся ровно один обмен,
|
||||
// поэтому такой вопрос там один.
|
||||
// За номер ключа цепляться НЕЛЬЗЯ, и сортировать по нему тоже: номера живут
|
||||
// своей жизнью — после правок список перенумеровывается, а у вопросов они
|
||||
// теперь и вовсе отрицательные (замер 11.09.2026: -4, 2, -11, 8 для двух
|
||||
// обменов подряд). Сортировка по возрастанию отдавала ПОСЛЕДНИЙ вопрос вместо
|
||||
// первого, и правка уходила не в то сообщение. Берём порядок DOM: список
|
||||
// рисуется сверху вниз, значит первый отрисованный вопрос — он и есть.
|
||||
function __firstMsg() {
|
||||
const items = [...document.querySelectorAll('[data-virtual-list-item-key]')]
|
||||
.map(e => ({ e, k: Number(e.getAttribute('data-virtual-list-item-key')) }))
|
||||
.filter(o => Number.isFinite(o.k) && !o.e.querySelector('.ds-markdown'))
|
||||
.sort((a, b) => a.k - b.k);
|
||||
return items.length ? items[0].e : null;
|
||||
return [...document.querySelectorAll('[data-virtual-list-item-key]')]
|
||||
.find(e => !e.querySelector('.ds-markdown')) || null;
|
||||
}
|
||||
// Кнопки под сообщением (копировать, редактировать). Появляются по наведению
|
||||
// и прячутся, когда курсор уходит вниз к ним, поэтому жать только из DOM.
|
||||
@@ -628,92 +551,41 @@ async function openChatById(s, id, log) {
|
||||
throw new Error(`не удалось открыть чат ${id} — страница не загрузилась.`);
|
||||
}
|
||||
|
||||
// Настоящий клик мышью по координатам: el.click() у DeepSeek только красит
|
||||
// тумблер, но модель чата не меняет — проверяется по индикатору Instant/DeepThink.
|
||||
async function realClick(s, name) {
|
||||
// Щёлкнуть по тумблеру настоящим мышиным событием. DOM-вызов click() тут
|
||||
// тоже срабатывает, но мышь проверена на большем числе случаев и работает
|
||||
// даже там, где React глотает синтетический клик.
|
||||
async function clickToggle(s, name) {
|
||||
const box = await s.eval(`(() => { ${PAGE_HELPERS}
|
||||
const el = [...document.querySelectorAll('div[role="button"], button, [class*="toggle-button"]')]
|
||||
const el = [...document.querySelectorAll('.ds-toggle-button')]
|
||||
.filter(__visible)
|
||||
.filter(e => !/toggle-button__/.test(e.getAttribute('class') || ''))
|
||||
.filter(e => !/ds-toggle-button__/.test(e.getAttribute('class') || ''))
|
||||
.find(e => (e.innerText || '').trim().toLowerCase() === ${JSON.stringify(name)}.toLowerCase());
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||||
})()`);
|
||||
if (!box) return false;
|
||||
const p = { x: box.x, y: box.y, button: 'left', clickCount: 1 };
|
||||
await s.send('Input.dispatchMouseEvent', { ...p, type: 'mouseMoved', button: 'none', clickCount: 0 });
|
||||
await sleep(120);
|
||||
await s.send('Input.dispatchMouseEvent', { ...p, type: 'mousePressed' });
|
||||
await s.send('Input.dispatchMouseEvent', { ...p, type: 'mouseReleased' });
|
||||
await hoverClick(s, box);
|
||||
await sleep(600);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Клик мышью по видимому элементу с ТОЧНЫМ текстом (пункт меню моделей).
|
||||
async function clickExactText(s, text) {
|
||||
const box = await s.eval(`(() => { ${PAGE_HELPERS}
|
||||
const el = [...document.querySelectorAll('div, span, li, button')].filter(__visible)
|
||||
.filter(e => (e.innerText || '').trim() === ${JSON.stringify(text)})
|
||||
.sort((a, b) => a.getBoundingClientRect().width - b.getBoundingClientRect().width)[0];
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||||
})()`);
|
||||
if (!box) return false;
|
||||
const p = { x: box.x, y: box.y, button: 'left', clickCount: 1 };
|
||||
await s.send('Input.dispatchMouseEvent', { ...p, type: 'mouseMoved', button: 'none', clickCount: 0 });
|
||||
await sleep(120);
|
||||
await s.send('Input.dispatchMouseEvent', { ...p, type: 'mousePressed' });
|
||||
await s.send('Input.dispatchMouseEvent', { ...p, type: 'mouseReleased' });
|
||||
await sleep(700);
|
||||
return true;
|
||||
}
|
||||
|
||||
// У DeepSeek два независимых механизма: выпадающий выбор МОДЕЛИ
|
||||
// (Instant / Expert / Vision) и тумблеры DeepThink / Search поверх неё.
|
||||
async function ensureModel(s, wantModel, wantToggle, log) {
|
||||
const read = async () => (await s.eval(`(() => { ${PAGE_HELPERS} return __modelLabel(); })()`))[0] || '?';
|
||||
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
const cur = await read();
|
||||
if (cur.toLowerCase() === wantModel.toLowerCase()) break;
|
||||
if (attempt === 3) {
|
||||
throw new Error(`не удалось выбрать модель ${wantModel} — выбрана ${cur}.`);
|
||||
}
|
||||
const box = await s.eval(`(() => { ${PAGE_HELPERS}
|
||||
const el = document.querySelector('[data-model-type="${wantModel}"][role="radio"]');
|
||||
if (!el || !__visible(el)) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||||
})()`);
|
||||
if (!box) throw new Error(`переключатель модели ${wantModel} не найден (чат уже начат?)`);
|
||||
const p = { x: box.x, y: box.y, button: 'left', clickCount: 1 };
|
||||
await s.send('Input.dispatchMouseEvent', { ...p, type: 'mouseMoved', button: 'none', clickCount: 0 });
|
||||
await sleep(120);
|
||||
await s.send('Input.dispatchMouseEvent', { ...p, type: 'mousePressed' });
|
||||
await s.send('Input.dispatchMouseEvent', { ...p, type: 'mouseReleased' });
|
||||
await sleep(700);
|
||||
log(`выбор модели: попытка ${attempt} → ${await read()}`);
|
||||
// DeepThink выключаем перед каждым вопросом: он только показывает размышления
|
||||
// модели и раздувает ответ, а модель и без него та же самая — выбора моделей
|
||||
// у DeepSeek больше нет.
|
||||
//
|
||||
// Search НЕ трогаем намеренно: это тумблер человека. Как поставлен во вкладке,
|
||||
// так мост и спрашивает — иначе мост отнимал бы единственный оставшийся
|
||||
// способ управлять поиском в интернете.
|
||||
async function ensureNoDeepThink(s, log) {
|
||||
const isOn = () => s.eval(`(() => { ${PAGE_HELPERS}
|
||||
return __toggles().some(t => /deepthink/i.test(t.name) && t.on); })()`);
|
||||
if (!await isOn()) return;
|
||||
if (!await clickToggle(s, 'DeepThink')) {
|
||||
log('тумблер DeepThink не найден — оставляю как есть');
|
||||
return;
|
||||
}
|
||||
|
||||
if (wantToggle) {
|
||||
const before = await s.eval(`(() => { ${PAGE_HELPERS} return __toggles(); })()`);
|
||||
const t = before.find(x => x.name.toLowerCase() === wantToggle.toLowerCase());
|
||||
if (t && !t.on) {
|
||||
await realClick(s, wantToggle);
|
||||
const after = await s.eval(`(() => { ${PAGE_HELPERS} return __toggles(); })()`);
|
||||
const now = after.find(x => x.name.toLowerCase() === wantToggle.toLowerCase());
|
||||
log(`тумблер ${wantToggle}: ${now?.on ? 'включён' : 'НЕ включился'}`);
|
||||
} else if (t?.on) {
|
||||
log(`тумблер ${wantToggle}: уже включён`);
|
||||
}
|
||||
}
|
||||
|
||||
const model = await read();
|
||||
const toggles = await s.eval(`(() => { ${PAGE_HELPERS} return __toggles(); })()`);
|
||||
const on = toggles.filter(x => x.on).map(x => x.name);
|
||||
return on.length ? `${model}+${on.join('+')}` : model;
|
||||
log(await isOn() ? 'DeepThink выключить не удалось' : 'DeepThink выключен');
|
||||
}
|
||||
|
||||
// Ответ считается готовым, когда его текст перестал меняться.
|
||||
@@ -925,15 +797,11 @@ async function deleteChat(s, id, log = () => {}) {
|
||||
// Стоит недорого: список чатов — один eval, и если лишнего нет, дальше ничего
|
||||
// не происходит.
|
||||
async function pruneChats(s, log = () => {}) {
|
||||
let keep = new Set();
|
||||
try {
|
||||
const map = JSON.parse(await readFile(CHATS_FILE, 'utf8'));
|
||||
keep = new Set(Object.values(map).map(v => v && v.id).filter(Boolean));
|
||||
} catch { return 0; } // нет chats.json — не знаем, что беречь, не трогаем
|
||||
if (!keep.size) return 0;
|
||||
const keep = await savedChatId();
|
||||
if (!keep) return 0; // не знаем, что беречь, — не трогаем ничего
|
||||
|
||||
const all = await s.eval(`(() => { ${PAGE_HELPERS} return __chatIds(); })()`);
|
||||
const extra = all.filter(c => !keep.has(c.id));
|
||||
const extra = all.filter(c => c.id !== keep);
|
||||
if (!extra.length) return 0;
|
||||
|
||||
log(`лишних чатов в сайдбаре: ${extra.length}, убираю`);
|
||||
@@ -946,68 +814,48 @@ async function pruneChats(s, log = () => {}) {
|
||||
return done;
|
||||
}
|
||||
|
||||
// Роль → настройки нового чата. Тот же список, что использует init.
|
||||
const CHAT_PLAN = {
|
||||
think: { model: 'expert', toggle: null, hello: 'Рабочий чат для рассуждений. Отвечай кратко.' },
|
||||
search: { model: 'default', toggle: 'Search', hello: 'Рабочий чат для поиска в интернете. Отвечай кратко.' },
|
||||
vision: { model: 'vision', toggle: null, hello: 'Рабочий чат для разбора картинок. Отвечай кратко.' },
|
||||
};
|
||||
|
||||
// DeepSeek разрешает править первое сообщение лишь ШЕСТЬ раз, дальше кнопка
|
||||
// правки перестаёт открывать поле. Поэтому чат — расходник: как только правки
|
||||
// кончились, заводим новый с тем же режимом и переписываем chats.json.
|
||||
// кончились, заводим новый и переписываем chats.json.
|
||||
//
|
||||
// firstText — настоящий вопрос вместо приветствия. Раньше чат
|
||||
// всегда открывался служебной фразой, а вопрос уходил ПРАВКОЙ этой фразы: круг
|
||||
// DeepSeek тратился впустую и одна правка из шести — тоже. Режим чата и его id
|
||||
// появляются от любого первого сообщения, так что якорь не нужен.
|
||||
// Возвращает { id, answered } — answered означает, что ответ на firstText уже
|
||||
// получен и вызывающему остаётся его забрать, а не спрашивать заново.
|
||||
async function makeChat(s, role, log, firstText = null, timeout = 300) {
|
||||
const step = CHAT_PLAN[role] || CHAT_PLAN.think;
|
||||
// Чат сразу открывается настоящим вопросом. Раньше он открывался служебным
|
||||
// приветствием, а вопрос уходил ПРАВКОЙ этой фразы: круг DeepSeek тратился
|
||||
// впустую и одна правка из шести — тоже.
|
||||
async function makeChat(s, log, firstText, timeout = 300, imageBytes = null) {
|
||||
await clickNewChat(s, log);
|
||||
await sleep(1500);
|
||||
|
||||
await ensureModel(s, step.model, step.toggle, log);
|
||||
const put = await s.eval(SET_TEXT(firstText || step.hello));
|
||||
await ensureNoDeepThink(s, log);
|
||||
const put = await s.eval(SET_TEXT(firstText));
|
||||
if (!put.ok) throw new Error(put.reason);
|
||||
if (imageBytes) await attachImage(s, imageBytes, log);
|
||||
await sleep(300);
|
||||
await sendAndConfirm(s, log);
|
||||
const answer = await waitForAnswer(s, put.before,
|
||||
firstText ? timeout : 420, log, put.textBefore);
|
||||
const answer = await waitForAnswer(s, put.before, timeout, log, put.textBefore);
|
||||
|
||||
// Ждём id, отличный от прежнего. Просто «непустой» тут мало: если переход
|
||||
// не состоялся, в адресе остаётся старый id, и он выглядит как новый.
|
||||
const mapFile = CHATS_FILE;
|
||||
let prev = null;
|
||||
try { prev = JSON.parse(await readFile(mapFile, 'utf8'))[role]?.id ?? null; } catch { /* нет файла */ }
|
||||
const old = await savedChatId();
|
||||
|
||||
let id = null;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
id = await s.eval(`(() => { ${PAGE_HELPERS} return __chatId(); })()`);
|
||||
if (id && id !== prev) break;
|
||||
if (id && id !== old) break;
|
||||
id = null;
|
||||
await sleep(500);
|
||||
}
|
||||
if (!id) throw new Error('новый чат создан, но его id не появился в адресе');
|
||||
|
||||
const file = mapFile;
|
||||
let map = {};
|
||||
try { map = JSON.parse(await readFile(file, 'utf8')); } catch { /* заведём заново */ }
|
||||
|
||||
// Чат — расходник, и раньше отработавшие копились в сайдбаре без счёта: за
|
||||
// сутки работы Эмилии их набралось семнадцать. Удаляем свой прежний чат этой
|
||||
// роли сразу, как только новый создан и записан.
|
||||
const old = map[role] && map[role].id;
|
||||
map[role] = { id, mode: step.toggle ? `${step.model}+${step.toggle}` : step.model };
|
||||
await writeFile(file, JSON.stringify(map, null, 2), 'utf8');
|
||||
log(`создан новый чат ${role}: ${id}`);
|
||||
await writeFile(CHATS_FILE, JSON.stringify({ id }, null, 2), 'utf8');
|
||||
log(`создан новый чат: ${id}`);
|
||||
|
||||
// Отработавшие чаты копились в сайдбаре без счёта: за сутки работы Эмилии
|
||||
// их набралось семнадцать. Прежний убираем сразу, как только новый записан.
|
||||
if (old && old !== id) {
|
||||
try { await deleteChat(s, old, log); }
|
||||
catch (e) { log(`старый чат ${old} удалить не вышло: ${e.message}`); }
|
||||
}
|
||||
return { id, answer, answered: !!firstText };
|
||||
return { id, answer };
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
@@ -1156,14 +1004,13 @@ async function attachImage(s, bytes, log) {
|
||||
|
||||
// Один вопрос — один ответ. Всё остальное (какой чат, жив ли он, не кончились
|
||||
// ли правки) решается здесь и наружу не торчит.
|
||||
export async function ask({ prompt, role = 'think', imageBytes = null,
|
||||
export async function ask({ prompt, imageBytes = null,
|
||||
timeout = 300, log = () => {} }) {
|
||||
if (!String(prompt || '').trim()) throw new Error('пустой вопрос');
|
||||
if (!ROLES.includes(role)) role = 'think';
|
||||
await ensureBrowser(log);
|
||||
|
||||
try {
|
||||
return await askOnce({ prompt, role, imageBytes, timeout, log });
|
||||
return await askOnce({ prompt, imageBytes, timeout, log });
|
||||
} catch (e) {
|
||||
if (!isLostTab(e)) throw e;
|
||||
// Вкладку закрыли или браузер уронили посреди работы. Поднимаем заново
|
||||
@@ -1174,53 +1021,49 @@ export async function ask({ prompt, role = 'think', imageBytes = null,
|
||||
// успела отправить вопрос, вторая его перепишет, а не задаст дважды.
|
||||
log(`связь с вкладкой потеряна (${e.message}); поднимаю и повторяю`);
|
||||
await ensureBrowser(log);
|
||||
return askOnce({ prompt, role, imageBytes, timeout, log });
|
||||
return askOnce({ prompt, imageBytes, timeout, log });
|
||||
}
|
||||
}
|
||||
|
||||
async function askOnce({ prompt, role, imageBytes, timeout, log }) {
|
||||
async function askOnce({ prompt, imageBytes, timeout, log }) {
|
||||
return withChat(async (s) => {
|
||||
await pruneChats(s, log); // сайдбар держим чистым
|
||||
|
||||
const known = (await chatsMap())[role]?.id ?? null;
|
||||
const known = await savedChatId();
|
||||
const alive = known && await s.eval(`(() => { ${PAGE_HELPERS}
|
||||
return __chatIds().some(c => c.id === ${JSON.stringify(String(known))}); })()`);
|
||||
|
||||
// Чата нужного режима нет — заводим сразу с вопросом. Отдельное приветствие
|
||||
// стоило бы лишнего круга DeepSeek и одной правки из шести, а нужно не было:
|
||||
// режим и id чата появляются от любого первого сообщения.
|
||||
if (!alive) {
|
||||
log(`чат ${role} не найден, создаю сразу с вопросом`);
|
||||
const made = await makeChat(s, role, log, prompt, timeout);
|
||||
// Картинка — всегда НОВЫЙ чат. Вложение цепляется к полю ввода, а правка
|
||||
// идёт в отдельном поле над ним: в старый чат картинку не перенести, и
|
||||
// отправка новым сообщением ломала единственный уговор, на котором всё
|
||||
// держится, — «в чате ровно один обмен». Поймано 11.09.2026: после такого
|
||||
// сообщения чат оставался из двух обменов, правка уходила в чужой вопрос,
|
||||
// а наружу отдавался ответ на предыдущий.
|
||||
//
|
||||
// Рабочего чата нет — тоже заводим, сразу с вопросом. Отдельное приветствие
|
||||
// стоило бы лишнего круга DeepSeek и одной правки из шести, а нужно
|
||||
// не было: id чата появляется от любого первого сообщения.
|
||||
if (!alive || imageBytes) {
|
||||
log(imageBytes ? 'вопрос с картинкой — завожу новый чат'
|
||||
: 'рабочий чат не найден, создаю сразу с вопросом');
|
||||
const made = await makeChat(s, log, prompt, timeout, imageBytes);
|
||||
return made.answer;
|
||||
}
|
||||
|
||||
await openChatById(s, String(known), log);
|
||||
if (imageBytes) await attachImage(s, imageBytes, log);
|
||||
await ensureNoDeepThink(s, log);
|
||||
|
||||
// С картинкой — только новым сообщением: вложение попадает в поле ввода,
|
||||
// а правка идёт в отдельном поле над ним, и картинка туда не переезжает.
|
||||
const canEdit = !imageBytes && await s.eval(
|
||||
`(() => { ${PAGE_HELPERS} return !!__firstMsg(); })()`);
|
||||
|
||||
if (canEdit) {
|
||||
try {
|
||||
const sent = await editFirstAndSend(s, prompt, log);
|
||||
return await waitForAnswer(s, sent.before, timeout, log, sent.textBefore);
|
||||
} catch (e) {
|
||||
// Единственная ожидаемая причина — исчерпан лимит правок (их шесть).
|
||||
if (!/поле правки не открылось/.test(String(e.message || e))) throw e;
|
||||
log('правки в этом чате кончились, завожу новый сразу с вопросом');
|
||||
const made = await makeChat(s, role, log, prompt, timeout);
|
||||
return made.answer;
|
||||
}
|
||||
try {
|
||||
const sent = await editFirstAndSend(s, prompt, log);
|
||||
return await waitForAnswer(s, sent.before, timeout, log, sent.textBefore);
|
||||
} catch (e) {
|
||||
// Ожидаемых причин две: исчерпан лимит правок (их шесть) и чат без
|
||||
// единого сообщения — например, его завели руками.
|
||||
if (!/поле правки не открылось|первое сообщение чата не найдено/.test(String(e.message || e))) throw e;
|
||||
log('править нечего или правки кончились, завожу новый чат сразу с вопросом');
|
||||
const made = await makeChat(s, log, prompt, timeout);
|
||||
return made.answer;
|
||||
}
|
||||
|
||||
const put = await s.eval(SET_TEXT(prompt));
|
||||
if (!put.ok) throw new Error(put.reason);
|
||||
await sleep(250);
|
||||
await sendAndConfirm(s, log);
|
||||
return waitForAnswer(s, put.before, timeout, log, put.textBefore);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1236,9 +1079,10 @@ export async function probe() {
|
||||
answerBlocks: a.nodes.length,
|
||||
generating: __isGenerating(),
|
||||
chats: __chatIds(),
|
||||
toggles: __toggles(),
|
||||
needsLogin: /log ?in|войти|sign in/i.test(document.body.innerText.slice(0, 3000)),
|
||||
};
|
||||
})()`);
|
||||
return { tab: tab.url, ...info, chatsFile: await chatsMap() };
|
||||
return { tab: tab.url, ...info, workChat: await savedChatId() };
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user