Самодостаточность: движок браузера переехал внутрь, DeepShim больше не нужен

Раньше сервис был обёрткой: каждый запрос спавнил ollama_orchestro/ask.mjs
дочерним процессом, гонял вопрос и ответ через временные файлы и читал
чужой config.json ради профиля браузера. Работать это могло только рядом
с установленным DeepShim.

Теперь всё своё. browser.mjs — перенесённый из ask.mjs движок: CDP-сессия,
помощники страницы со всем, что удалось выяснить про разметку DeepSeek,
отправка с подтверждением, ожидание ответа по состоянию кнопки, работа
с чатами. Пульт, разбор патчей и git остались в DeepShim, сюда не поехали.

Побочно ушли дочерний процесс на каждый запрос и временные файлы: вопрос
и ответ теперь просто переменные. Файл на диске остался только для картинки —
<input type="file"> иначе не наполнить.

Свои настройки вместо чужих: browserExe, profileDir и cdpPort в config.json,
рядом config.local.json для путей конкретной машины (в репозиторий не идёт).
Свой chats.json, тоже не в репозитории: это рабочее состояние, оно
переписывается при каждой смене чата. Образец — chats.example.json.

Добавлен GET /api/probe: что видно во вкладке прямо сейчас.

ВАЖНО: профиль браузера должен быть отдельный. Перед каждым вопросом сайдбар
подчищается от всего, чего нет в своём chats.json, поэтому две программы
на одном профиле удаляют чаты друг друга. Проверено вживую, описано в README.

Проверки: node test.mjs; сквозной прогон с нуля — свой чат заведён сам,
ответ верный; пять правок в одном чате, на шестом запросе новый чат, старый
удалён, сайдбар не растёт.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Alex Cube
2026-08-26 13:09:41 +03:00
co-authored by Claude Opus 5
parent f5cf061fcc
commit 8c52cf101c
6 changed files with 1307 additions and 296 deletions
+39 -131
View File
@@ -6,146 +6,43 @@
// и отдаёт наружу протокол Ollama: /api/tags и /api/chat. Любая программа,
// которая уже умеет ходить в Ollama, подключается сюда сменой одного адреса.
//
// Что внутри происходит: запрос → файл → мост ollama_orchestro/ask.mjs →
// правка первого сообщения в чате DeepSeek → ответ → файл → HTTP.
// Здесь только HTTP и перевод формата. Всё, что касается браузера и разметки
// DeepSeek, живёт в browser.mjs.
//
// Зависимостей нет: Node 22+ со встроенным fetch.
// Зависимостей нет: Node 22+ со встроенными fetch и WebSocket.
import { createServer } from 'node:http';
import { spawn } from 'node:child_process';
import { readFile, writeFile, rm, mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { readFile } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
// fileURLToPath, а не разбор pathname вручную: путь проекта содержит «#»,
import { configure, ask, probe, browserAlive, ROLES } from './browser.mjs';
// fileURLToPath, а не разбор pathname вручную: путь проекта может содержать «#»,
// и в URL он приезжает как %23 — самодельный разбор ломается именно на нём.
const HERE = dirname(fileURLToPath(import.meta.url));
const cfg = {
port: 11500,
host: '0.0.0.0',
bridge: 'C:/#Projects/ollama_orchestro/ask.mjs',
cdpPort: 9333,
browserExe: '',
profileDir: '',
chatUrl: 'https://chat.deepseek.com/',
defaultRole: 'think',
timeout: 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);
// Отладочный порт браузера живёт в конфиге моста: он один на все проекты,
// и держать его копию здесь значит однажды разойтись.
const bridgeDir = dirname(cfg.bridge);
const bridgeCfg = JSON.parse(
await readFile(join(bridgeDir, 'config.json'), 'utf8').catch(() => '{}'));
const cdpPort = Number(process.env.DEEPSEEK_CDP_PORT || bridgeCfg.port || 9333);
configure(cfg);
// Роли — это отдельные чаты DeepSeek под разные режимы (обычный, с поиском,
// со зрением). Режим выбирается при создании чата и потом не меняется, поэтому
// чатов несколько. Снаружи они выглядят как разные «модели» Ollama.
const ROLES = ['think', 'search', 'vision'];
const MODELS = ROLES.map(r => 'deepseek:' + r);
// ------------------------------------------------------------------ браузер
const cdp = path => fetch(`http://127.0.0.1:${cdpPort}${path}`,
{ signal: AbortSignal.timeout(3000) });
async function browserAlive() {
try {
return (await cdp('/json/version')).ok;
} catch { return false; }
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
// Флаги не украшение. Замер в ollama_orchestro 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',
];
// Поднять браузер, если он не отвечает, и открыть вкладку с чатом, если её нет.
// Профиль и путь к браузеру берём из конфига моста — там уже выполнен вход
// в DeepSeek, а второй профиль означал бы второй логин.
//
// Браузер запускается напрямую, а не через browser-launch.ps1 из ollama_orchestro:
// тот скрипт рассчитан на человека за консолью — на занятом профиле он
// спрашивает Read-Host, а у сервиса некому отвечать.
async function ensureBrowser() {
if (!(await browserAlive())) {
if (!bridgeCfg.profileDir || !bridgeCfg.browserExe) {
throw new Error(
`Браузер не отвечает на порту ${cdpPort}, и запустить его сам не могу: ` +
`в ${join(bridgeDir, 'config.json')} не задан profileDir или browserExe.`);
}
console.log('Браузер не отвечает, поднимаю...');
spawn(bridgeCfg.browserExe,
[`--user-data-dir=${bridgeCfg.profileDir}`,
`--remote-debugging-port=${cdpPort}`,
...BROWSER_FLAGS, cfg.chatUrl],
{ detached: true, stdio: 'ignore' }).unref();
// Холодный профиль поднимается неспешно; 60 секунд с запасом.
for (let i = 0; i < 30 && !(await browserAlive()); i++) await sleep(2000);
if (!(await browserAlive())) {
throw new Error(
`Браузер так и не открыл отладочный порт ${cdpPort}. Скорее всего этот ` +
`профиль уже открыт БЕЗ отладочного порта: новый процесс отдаёт ссылку ` +
`старому окну и завершается. Закройте окно с профилем ` +
`${bridgeCfg.profileDir} и повторите.`);
}
}
const list = await (await cdp('/json/list')).json().catch(() => []);
if (list.some(t => String(t.url || '').includes('deepseek'))) return;
// Новая вкладка в свежих сборках Chromium открывается ТОЛЬКО методом PUT:
// на GET протокол отвечает 405 и молча ничего не делает.
const r = await fetch(
`http://127.0.0.1:${cdpPort}/json/new?url=${encodeURIComponent(cfg.chatUrl)}`,
{ method: 'PUT' });
if (!r.ok) throw new Error(`Не удалось открыть вкладку DeepSeek: ${r.status}.`);
await sleep(4000); // дать чату прогрузиться до первого вопроса
}
// ------------------------------------------------------------------ мост
// Вопрос и ответ ездят файлами, а не через stdout: вопрос бывает на десяток
// килобайт, ответ — на сотню, и труба тут только мешает.
async function askBridge(prompt, role, imageBytes) {
await ensureBrowser();
const dir = await mkdtemp(join(tmpdir(), 'dsapi-'));
const qFile = join(dir, 'q.txt'), aFile = join(dir, 'a.txt');
const iFile = join(dir, 'image.jpg');
try {
await writeFile(qFile, prompt, 'utf8');
const args = ['ask', '--file', qFile, '--out', aFile, '--quiet', '--no-seed',
'--chat', role, '--timeout', String(cfg.timeout)];
if (imageBytes) {
await writeFile(iFile, imageBytes);
args.push('--image', iFile);
}
await new Promise((resolve, reject) => {
const ch = spawn(process.execPath, [cfg.bridge, ...args],
{ cwd: bridgeDir, stdio: ['ignore', 'ignore', 'pipe'] });
let err = '';
ch.stderr.on('data', d => { err += d; });
ch.on('error', reject);
ch.on('close', c => (c === 0 ? resolve() : reject(new Error(
`мост вышел с кодом ${c}. ${err.trim().slice(0, 300)}`))));
});
return await readFile(aFile, 'utf8');
} finally {
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
}
// Вкладка одна, и два запроса разом затрут друг другу поле ввода. Поэтому
// очередь: запросы идут строго по одному.
// ponytail: очередь без ограничения длины — если клиент завалит сервис
@@ -221,7 +118,7 @@ const server = createServer(async (req, res) => {
if (!(await browserAlive())) {
return json(res, 503, {
models: [],
error: `Браузер с отладочным портом ${cdpPort} не отвечает. ` +
error: `Браузер с отладочным портом ${cfg.cdpPort} не отвечает. ` +
'Он поднимется сам при первом запросе к /api/chat; если этого ' +
'не произошло — профиль, скорее всего, уже открыт без ' +
'отладочного порта, закройте то окно.',
@@ -239,15 +136,23 @@ const server = createServer(async (req, res) => {
return json(res, 200, { version: 'deepseek-api' });
}
// Своё, сверх протокола 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 image = imageOf(body.messages);
const role = image ? 'vision' : roleOf(body.model);
const imageBytes = imageOf(body.messages);
const role = imageBytes ? 'vision' : roleOf(body.model);
const started = Date.now();
console.log(`[${role}] ${prompt.length} симв.${image ? ' + картинка' : ''}`);
const answer = await serial(() => askBridge(prompt, role, image));
console.log(`[${role}] ${prompt.length} симв.${imageBytes ? ' + картинка' : ''}`);
const answer = await serial(() => ask({
prompt, role, imageBytes, timeout: cfg.timeout,
log: m => console.log(` ${m}`),
}));
console.log(`[${role}] ответ ${answer.length} симв. за ${
Math.round((Date.now() - started) / 1000)} с`);
const model = body.model || ('deepseek:' + role);
@@ -268,7 +173,9 @@ const server = createServer(async (req, res) => {
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(() => askBridge(prompt, role, null));
const answer = await serial(() => ask({
prompt, role, timeout: cfg.timeout, log: m => console.log(` ${m}`),
}));
const head = { model: body.model || ('deepseek:' + role), created_at: now() };
if (body.stream === false) {
return json(res, 200, { ...head, response: answer, done: true, done_reason: 'stop' });
@@ -296,14 +203,15 @@ const server = createServer(async (req, res) => {
// По умолчанию слушаем все адреса: подключаются и с этой машины, и с соседней —
// ровно как к самой 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(`Модели: ${MODELS.join(', ')}`);
console.log(`Мост: ${cfg.bridge} (браузер на порту ${cdpPort})`);
console.log('Остановить — Ctrl+C');
});
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(`Браузер: порт ${cfg.cdpPort}, профиль ${cfg.profileDir || '(не задан)'}`);
console.log('Остановить — Ctrl+C');
});
}