#!/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(); } }