#!/usr/bin/env node // DeepSeek, притворяющийся Ollama. // // Веб-чат DeepSeek — не API: у него нет ни ключа, ни эндпойнта, зато есть // вкладка в браузере, куда можно писать. Этот сервис поднимает такую вкладку // и отдаёт наружу протокол Ollama: /api/tags и /api/chat. Любая программа, // которая уже умеет ходить в Ollama, подключается сюда сменой одного адреса. // // Что внутри происходит: запрос → файл → мост ollama_orchestro/ask.mjs → // правка первого сообщения в чате DeepSeek → ответ → файл → HTTP. // // Зависимостей нет: Node 22+ со встроенным fetch. 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 { join, dirname } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; // 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', chatUrl: 'https://chat.deepseek.com/', defaultRole: 'think', timeout: 300, ...JSON.parse(await readFile(join(HERE, 'config.json'), 'utf8').catch(() => '{}')), }; if (process.env.DEEPSEEK_API_PORT) cfg.port = Number(process.env.DEEPSEEK_API_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); // Роли — это отдельные чаты 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: очередь без ограничения длины — если клиент завалит сервис // запросами, они просто будут ждать. Появится нужда — резать по длине очереди. let chain = Promise.resolve(); function serial(fn) { const next = chain.then(fn, fn); chain = next.catch(() => {}); return next; } // ------------------------------------------------------------------ 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 тоже // сохраняем: в редких сценариях клиент подкладывает начало ответа. 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(); // Клиенты 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: `Браузер с отладочным портом ${cdpPort} не отвечает. ` + 'Он поднимется сам при первом запросе к /api/chat; если этого ' + 'не произошло — профиль, скорее всего, уже открыт без ' + 'отладочного порта, закройте то окно.', }); } return json(res, 200, { models: MODELS.map(m => ({ name: m, model: m, 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 === '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 started = Date.now(); console.log(`[${role}] ${prompt.length} симв.${image ? ' + картинка' : ''}`); const answer = await serial(() => askBridge(prompt, role, image)); console.log(`[${role}] ответ ${answer.length} симв. за ${ Math.round((Date.now() - started) / 1000)} с`); const model = body.model || ('deepseek:' + role); 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 role = roleOf(body.model); const answer = await serial(() => askBridge(prompt, role, null)); 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' }); } 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(`Модели: ${MODELS.join(', ')}`); console.log(`Мост: ${cfg.bridge} (браузер на порту ${cdpPort})`); console.log('Остановить — Ctrl+C'); }); }