Files
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

316 lines
17 KiB
JavaScript
Raw Permalink 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.
#!/usr/bin/env node
// DeepSeek, притворяющийся Ollama.
//
// Веб-чат DeepSeek — не API: у него нет ни ключа, ни эндпойнта, зато есть
// вкладка в браузере, куда можно писать. Этот сервис поднимает такую вкладку
// и отдаёт наружу протокол Ollama: /api/tags и /api/chat. Любая программа,
// которая уже умеет ходить в Ollama, подключается сюда сменой одного адреса.
//
// Здесь только HTTP и перевод формата. Всё, что касается браузера и разметки
// DeepSeek, живёт в browser.mjs.
//
// Зависимостей нет: Node 22+ со встроенными fetch и WebSocket.
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { configure, ask, probe, revive, browserAlive } from './browser.mjs';
// fileURLToPath, а не разбор pathname вручную: путь проекта может содержать «#»,
// и в URL он приезжает как %23 — самодельный разбор ломается именно на нём.
const HERE = dirname(fileURLToPath(import.meta.url));
const cfg = {
port: 11500,
host: '0.0.0.0',
cdpPort: 9333,
browserExe: '',
profileDir: '',
chatUrl: 'https://chat.deepseek.com/',
timeout: 300,
// Сторож: поднимать браузер и вкладку сам, не дожидаясь вопроса. Иначе
// восстановление происходит внутри запроса, и клиент платит за него
// 15-60 секундами своего времени ожидания.
watchdog: true,
watchdogSec: 30,
// Потолок отсрочки. Если браузер не поднимается (обычно профиль уже открыт
// другим окном без отладочного порта), проверки разрежаются вдвое каждый
// раз — иначе сторож плодил бы процессы каждые полминуты без конца.
watchdogMaxSec: 300,
...JSON.parse(await readFile(join(HERE, 'config.json'), 'utf8').catch(() => '{}')),
// Личные пути — сюда: этот файл в репозиторий не попадает, и обновление
// проекта не затрёт настройки конкретной машины.
...JSON.parse(await readFile(join(HERE, 'config.local.json'), 'utf8').catch(() => '{}')),
};
if (process.env.DEEPSEEK_API_PORT) cfg.port = Number(process.env.DEEPSEEK_API_PORT);
if (process.env.DEEPSEEK_CDP_PORT) cfg.cdpPort = Number(process.env.DEEPSEEK_CDP_PORT);
configure(cfg);
// Модель одна: выбор из трёх (Instant / Expert / Vision) DeepSeek из веб-морды
// убрал, оставшаяся универсальна — она же разбирает картинки. Имя модели,
// которое пришлёт клиент, мы не смотрим вовсе.
const MODEL = 'deepseek:chat';
// Состояние для индикаторов. Обновляется сторожем и запросами; отдаётся
// по /api/state, НЕ трогая вкладку — на него можно смотреть хоть раз в секунду.
const state = {
browser: false, // отвечает ли отладочный порт
ready: false, // браузер поднят и вкладка с чатом на месте
busy: false, // прямо сейчас идёт запрос
queue: 0, // сколько ждёт в очереди
since: null, // когда начался текущий запрос
error: null, // последняя беда, понятным текстом
checked: null, // когда сторож смотрел в последний раз
};
// Вкладка одна, и два запроса разом затрут друг другу поле ввода. Поэтому
// очередь: запросы идут строго по одному.
// ponytail: очередь без ограничения длины — если клиент завалит сервис
// запросами, они просто будут ждать. Появится нужда — резать по длине очереди.
let chain = Promise.resolve();
function serial(fn) {
state.queue++;
const next = chain.then(fn, fn);
chain = next.catch(() => {});
next.then(() => { state.queue--; }, () => { state.queue--; });
return next;
}
// ------------------------------------------------------------------ сторож
// Сторож ходит через ТУ ЖЕ очередь, что и запросы. Иначе он полез бы в браузер
// в тот момент, когда DeepSeek дописывает ответ, — а вкладка одна.
let watchTimer = null;
let watchDelay = 0;
function scheduleWatch(delaySec) {
if (!cfg.watchdog) return;
clearTimeout(watchTimer);
watchDelay = delaySec;
watchTimer = setTimeout(runWatch, delaySec * 1000);
watchTimer.unref?.(); // сторож не должен держать процесс живым
}
async function runWatch() {
// Идёт запрос — он сам всё поднимет и проверит. Не мешаем и не ждём очереди:
// на длинном ответе сторож простоял бы минуты без всякой пользы.
if (state.busy) return scheduleWatch(cfg.watchdogSec);
const r = await serial(() => revive(m => console.log(` сторож: ${m}`)));
state.checked = now();
state.browser = r.browser;
state.ready = r.ok;
state.error = r.ok ? null : r.error;
if (r.ok) return scheduleWatch(cfg.watchdogSec);
// Не поднялось — разрежаем проверки. Чаще всего причина не лечится сама:
// профиль открыт другим окном без отладочного порта, и человеку надо это окно
// закрыть. Долбиться каждые полминуты бессмысленно и плодит процессы.
const next = Math.min((watchDelay || cfg.watchdogSec) * 2, cfg.watchdogMaxSec);
console.error(`Сторож: ${r.error} Следующая попытка через ${next} с.`);
scheduleWatch(next);
}
// ------------------------------------------------------------------ Ollama
// Ollama принимает роли system/user/assistant отдельными сообщениями, у чата
// DeepSeek такого разделения нет — склеиваем в один текст. Роль assistant тоже
// сохраняем: в редких сценариях клиент подкладывает начало ответа.
export function flatten(messages) {
return (messages || [])
.map(m => String(m?.content || '').trim())
.filter(Boolean)
.join('\n\n');
}
// Картинка приезжает в base64 внутри сообщения — так её кладёт Ollama.
export function imageOf(messages) {
for (const m of messages || []) {
const img = (m?.images || [])[0];
if (img) return Buffer.from(String(img), 'base64');
}
return null;
}
const json = (res, code, body) => {
const text = JSON.stringify(body);
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(text) });
res.end(text);
};
async function readBody(req) {
const chunks = [];
for await (const c of req) chunks.push(c);
return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
}
const now = () => new Date().toISOString();
// Обёртка вокруг одного запроса: держит state в правде, что бы ни случилось.
async function track(fn) {
state.busy = true; state.since = Date.now(); state.error = null;
try {
const out = await fn();
state.ready = true; state.browser = true;
return out;
} catch (e) {
state.error = e.message;
state.ready = false;
throw e;
} finally {
state.busy = false; state.since = null;
}
}
// Клиенты Ollama по умолчанию просят поток и ждут не один объект, а несколько
// строк JSON подряд. Настоящего потока у нас нет — веб-чат отдаёт ответ целиком,
// — но формат соблюсти обязаны, иначе клиент не разберёт ответ вообще.
// Поэтому поток из двух строк: весь текст, затем признак конца.
function stream(res, first, last) {
res.writeHead(200, { 'Content-Type': 'application/x-ndjson; charset=utf-8' });
res.write(JSON.stringify(first) + '\n');
res.end(JSON.stringify(last) + '\n');
}
const server = createServer(async (req, res) => {
const url = new URL(req.url, 'http://x');
try {
// Проверка связи. Клиенты Ollama дёргают /api/tags, чтобы понять, жив ли
// сервер, — и здесь это должно означать «браузер на месте». Иначе клиент
// сочтёт нас живыми и повиснет на первом же запросе на все пять минут.
if (req.method === 'GET' && (url.pathname === '/api/tags' || url.pathname === '/')) {
if (!(await browserAlive())) {
return json(res, 503, {
models: [],
error: `Браузер с отладочным портом ${cfg.cdpPort} не отвечает. ` +
'Он поднимется сам при первом запросе к /api/chat; если этого ' +
'не произошло — профиль, скорее всего, уже открыт без ' +
'отладочного порта, закройте то окно.',
});
}
return json(res, 200, {
models: [{
name: MODEL, model: MODEL, modified_at: now(), size: 0,
details: { family: 'deepseek', parameter_size: 'web', quantization_level: '-' },
}],
});
}
if (req.method === 'GET' && url.pathname === '/api/version') {
return json(res, 200, { version: 'deepseek-api' });
}
// Дешёвый снимок состояния для индикаторов: ни браузер, ни вкладку
// не трогает, поэтому спрашивать можно хоть раз в секунду.
if (req.method === 'GET' && url.pathname === '/api/state') {
return json(res, 200, {
...state,
seconds: state.busy && state.since ? Math.round((Date.now() - state.since) / 1000) : 0,
host: cfg.host, port: cfg.port, cdpPort: cfg.cdpPort,
watchdog: !!cfg.watchdog, watchdogSec: cfg.watchdogSec,
});
}
// Своё, сверх протокола Ollama: что видно во вкладке прямо сейчас.
if (req.method === 'GET' && url.pathname === '/api/probe') {
return json(res, 200, await serial(() => probe()));
}
if (req.method === 'POST' && url.pathname === '/api/chat') {
const body = await readBody(req);
const prompt = flatten(body.messages);
if (!prompt) return json(res, 400, { error: 'пустой запрос' });
const imageBytes = imageOf(body.messages);
const started = Date.now();
console.log(`вопрос ${prompt.length} симв.${imageBytes ? ' + картинка' : ''}`);
const answer = await serial(() => track(() => ask({
prompt, imageBytes, timeout: cfg.timeout,
log: m => console.log(` ${m}`),
})));
console.log(`ответ ${answer.length} симв. за ${
Math.round((Date.now() - started) / 1000)} с`);
// Имя модели возвращаем то, которое прислал клиент: иначе он решит, что
// ответила не та модель, которую он просил.
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 },
done: true, done_reason: 'stop' });
}
return stream(res,
{ ...head, message: { role: 'assistant', content: answer }, done: false },
{ ...head, message: { role: 'assistant', content: '' },
done: true, done_reason: 'stop' });
}
// /api/generate — тот же чат, только промпт строкой.
if (req.method === 'POST' && url.pathname === '/api/generate') {
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 answer = await serial(() => track(() => ask({
prompt, timeout: cfg.timeout, log: m => console.log(` ${m}`),
})));
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' });
}
return stream(res, { ...head, response: answer, done: false },
{ ...head, response: '', done: true, done_reason: 'stop' });
}
// Эмбеддинги веб-чат не отдаёт в принципе. Честный отказ лучше пустого
// вектора: клиент увидит причину, а не молча испорченный поиск.
if (url.pathname === '/api/embeddings' || url.pathname === '/api/embed') {
return json(res, 501, {
error: 'DeepSeek через веб-чат не умеет эмбеддинги. ' +
'Оставьте поиск по смыслу на настоящей Ollama.',
});
}
json(res, 404, { error: 'нет такого эндпойнта' });
} catch (e) {
console.error('Ошибка:', e.message);
json(res, 500, { error: String(e && e.message || e) });
}
});
// По умолчанию слушаем все адреса: подключаются и с этой машины, и с соседней —
// ровно как к самой Ollama. Сервис ходит в браузер под вашим логином, поэтому
// в недоверенной сети host в config.json стоит сузить до 127.0.0.1.
//
// Запускаемся только когда файл вызвали напрямую: перевод формата проверяется
// из test.mjs импортом, и поднимать ради этого сервер незачем.
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(`Модель: ${MODEL}`);
console.log(`Браузер: порт ${cfg.cdpPort}, профиль ${cfg.profileDir || '(не задан)'}`);
if (cfg.watchdog) {
console.log(`Сторож: проверка каждые ${cfg.watchdogSec} с`);
// Первая проверка сразу: если браузер уже открыт, вкладка подхватится,
// а если нет — поднимется, пока никто не ждёт ответа.
scheduleWatch(1);
} else {
console.log('Сторож выключен: браузер поднимется на первом запросе');
}
console.log(process.env.DEEPSEEK_TRAY
? 'Остановить — «Выход» в меню значка в трее'
: 'Остановить — Ctrl+C');
});
// Запущены из окна (tray.cs): оно держит нашу трубу stdin открытой. Труба
// закрылась — окна больше нет, и висеть дальше незачем: раньше закрытая
// консоль оставляла node в памяти. Браузер при этом переживает нас: он
// отдельный процесс и нужен человеку.
if (process.env.DEEPSEEK_TRAY) {
process.stdin.on('close', () => process.exit(0)).resume();
}
}