Одна модель вместо трёх: 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:
Alex Cube
2026-09-11 10:26:03 +03:00
co-authored by Claude Opus 5
parent adbcedb485
commit 6ccb5dd46b
6 changed files with 162 additions and 337 deletions
+17 -23
View File
@@ -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} с`);