У веб-версии DeepSeek нет API: ни ключа, ни эндпойнта, только вкладка
в браузере. Этот сервис держит такую вкладку и отдаёт наружу протокол
Ollama, поэтому любая программа, которая уже умеет ходить в Ollama,
подключается к DeepSeek сменой одного адреса.
GET /api/tags три "модели" по числу режимов чата;
503, пока браузер с чатом не поднят
GET /api/version
POST /api/chat images в сообщении уводят в чат со зрением
POST /api/generate
POST /api/embeddings 501: веб-чат векторов не отдаёт
Внутри — ollama_orchestro/ask.mjs дочерним процессом: он ничего не
экспортирует и выполняется при импорте, это программа командной строки.
Браузер поднимается напрямую из Node, а не через browser-launch.ps1 из
DeepShim: тот рассчитан на человека за консолью и на занятом профиле
спрашивает Read-Host, а у сервиса некому отвечать. Флаги окна оттуда
перенесены: у неактивного окна DeepSeek не дорисовывает блоки кода.
Чего нет: настоящего потока (формат соблюдается, но ответ приходит
целиком), temperature и num_predict, эмбеддингов, параллельных запросов —
вкладка одна, запросы идут очередью по одному.
Сервис слушает все адреса и ходит в DeepSeek под вашим логином. Только
доверенная сеть; сузить до этого компьютера — host в config.json.
Проверка перевода формата без браузера: node test.mjs
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
310 lines
16 KiB
JavaScript
310 lines
16 KiB
JavaScript
#!/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');
|
||
});
|
||
}
|