Раньше сервис был обёрткой: каждый запрос спавнил 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>
218 lines
11 KiB
JavaScript
218 lines
11 KiB
JavaScript
#!/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');
|
||
});
|
||
}
|