Самодостаточность: движок браузера переехал внутрь, 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:
co-authored by
Claude Opus 5
parent
f5cf061fcc
commit
8c52cf101c
+39
-131
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user