Files
deepseek-api/server.mjs
T
Alex CubeandClaude Opus 5 8c52cf101c Самодостаточность: движок браузера переехал внутрь, 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>
2026-08-26 13:09:41 +03:00

218 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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, 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',
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);
configure(cfg);
const MODELS = ROLES.map(r => 'deepseek:' + r);
// Вкладка одна, и два запроса разом затрут друг другу поле ввода. Поэтому
// очередь: запросы идут строго по одному.
// 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: `Браузер с отладочным портом ${cfg.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' });
}
// Своё, сверх протокола 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 role = imageBytes ? 'vision' : roleOf(body.model);
const started = Date.now();
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);
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(() => 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' });
}
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.cdpPort}, профиль ${cfg.profileDir || '(не задан)'}`);
console.log('Остановить — Ctrl+C');
});
}