Добавлены POST /v1/chat/completions и GET /v1/models. Описания инструментов
из поля tools вклеиваются в текст промпта, DeepSeek просят звать их блоком
<tool_call>{json}</tool_call>, а ответ разбирается обратно в OpenAI tool_calls.
Парсятся два формата: наш <tool_call> и родной DeepSeek invoke/parameter.
Позволяет подключать агентов на OpenAI-протоколе (например, Hermes) с рабочими
вызовами инструментов поверх веб-чата DeepSeek.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011W17JPwr6FwxMAKAHtgEwT
488 lines
25 KiB
JavaScript
488 lines
25 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, 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;
|
||
}
|
||
|
||
// ──────────────────── OpenAI /v1 с эмуляцией вызова инструментов ────────────
|
||
// Агенты (Hermes и пр.) говорят по OpenAI /v1/chat/completions и передают
|
||
// инструменты в поле tools, ожидая обратно tool_calls. У веб-чата DeepSeek нет
|
||
// ни ролей, ни функций — поэтому инструменты вклеиваем в текст промпта, просим
|
||
// модель звать их блоком <tool_call>{...}</tool_call>, а ответ разбираем обратно
|
||
// в tool_calls. Ради этого вся затея: DeepSeek держит формат надёжно, а слабые
|
||
// локальные модели через настоящий tool-calling его ломали.
|
||
|
||
export const TOOL_OPEN = '<tool_call>';
|
||
export const TOOL_CLOSE = '</tool_call>';
|
||
|
||
function toolInstructions(tools) {
|
||
const list = tools.map(t => {
|
||
const f = t.function || t;
|
||
return `- ${f.name}: ${f.description || ''}\n parameters (JSON Schema): ${JSON.stringify(f.parameters || {})}`;
|
||
}).join('\n');
|
||
return [
|
||
'You have tools. Available tools:', list, '',
|
||
`To call tools, output ONLY tool-call blocks and nothing else, one per line:`,
|
||
`${TOOL_OPEN}{"name":"<tool>","arguments":{ ... }}${TOOL_CLOSE}`,
|
||
'Rules:',
|
||
'- A tool call MUST be exactly that block; arguments MUST be valid JSON.',
|
||
'- When calling tools, output no other text — no prose, no markdown fences.',
|
||
'- After tool results arrive, either call more tools or give the final answer.',
|
||
'- Give a plain-text answer (no blocks) only when the task is finished.',
|
||
].join('\n');
|
||
}
|
||
|
||
// content OpenAI бывает строкой или массивом частей (текст/картинка).
|
||
function textOf(content) {
|
||
if (typeof content === 'string') return content;
|
||
if (Array.isArray(content)) {
|
||
return content.map(p => (typeof p === 'string' ? p : (p?.text || ''))).filter(Boolean).join('\n');
|
||
}
|
||
return String(content ?? '');
|
||
}
|
||
|
||
function safeArgs(a) {
|
||
if (a && typeof a === 'object') return a;
|
||
try { return JSON.parse(a || '{}'); } catch { return {}; }
|
||
}
|
||
|
||
// Разворачиваем OpenAI-историю (роли, tool_calls ассистента, результаты role:tool)
|
||
// в один текст для веб-чата.
|
||
export function renderMessages(messages) {
|
||
const parts = [];
|
||
for (const m of messages || []) {
|
||
const role = m.role || 'user';
|
||
if (role === 'system') { parts.push(`# System\n${textOf(m.content)}`); continue; }
|
||
if (role === 'tool') {
|
||
const id = m.tool_call_id || m.name || '';
|
||
parts.push(`# Tool result${id ? ` (${id})` : ''}\n${textOf(m.content)}`); continue;
|
||
}
|
||
if (role === 'assistant') {
|
||
let s = '# Assistant\n';
|
||
if (m.content) s += `${textOf(m.content)}\n`;
|
||
for (const tc of m.tool_calls || []) {
|
||
const fn = tc.function || {};
|
||
s += `${TOOL_OPEN}${JSON.stringify({ name: fn.name, arguments: safeArgs(fn.arguments) })}${TOOL_CLOSE}\n`;
|
||
}
|
||
parts.push(s.trimEnd()); continue;
|
||
}
|
||
parts.push(`# User\n${textOf(m.content)}`);
|
||
}
|
||
return parts.join('\n\n');
|
||
}
|
||
|
||
const mkCall = (name, args) => ({
|
||
id: 'call_' + Math.random().toString(36).slice(2, 10),
|
||
type: 'function',
|
||
function: { name, arguments: JSON.stringify(args ?? {}) },
|
||
});
|
||
|
||
// DeepSeek не всегда слушается инструкции про <tool_call>{json}: иногда отдаёт
|
||
// свой invoke/parameter-формат (<||DSML|| invoke name="t"> с параметрами-тегами).
|
||
// Разбираем и его — иначе вызов ушёл бы наружу текстом. Толерантно к любому
|
||
// мусору вокруг слов invoke/parameter (спецсимволы-разделители игнорируем).
|
||
function parseInvokeStyle(text) {
|
||
if (!/invoke\s+name=/i.test(text)) return { calls: [], clean: text };
|
||
const calls = [];
|
||
// Каждый invoke-блок: имя + его параметры до закрытия invoke.
|
||
const blockRe = /invoke\s+name="([^"]+)"([\s\S]*?)(?=invoke\s+name="|$)/gi;
|
||
const paramRe = /parameter\s+name="([^"]+)"([^>]*)>([\s\S]*?)<\/[^>]*?parameter/gi;
|
||
let b;
|
||
while ((b = blockRe.exec(text))) {
|
||
const name = b[1];
|
||
const args = {};
|
||
let p;
|
||
while ((p = paramRe.exec(b[2]))) {
|
||
const key = p[1], meta = p[2] || '', raw = p[3].trim();
|
||
// string="true" — брать как строку; иначе пробуем распознать число/bool/JSON.
|
||
if (/string="true"/i.test(meta)) args[key] = raw;
|
||
else { try { args[key] = JSON.parse(raw); } catch { args[key] = raw; } }
|
||
}
|
||
calls.push(mkCall(name, args));
|
||
}
|
||
// Вырезаем всё, что похоже на разметку вызова, из «чистого» текста.
|
||
const clean = text.replace(/<[^>]*?(invoke|parameter|calls)[^>]*?>/gi, '')
|
||
.replace(/<\/[^>]*?(invoke|parameter|calls)[^>]*?>/gi, '').trim();
|
||
return { calls, clean };
|
||
}
|
||
|
||
// Достаём вызовы инструментов из ответа модели: сперва наш <tool_call>{json},
|
||
// затем, если пусто, — invoke/parameter-формат DeepSeek.
|
||
export function parseToolCalls(text) {
|
||
const re = /<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/g;
|
||
const calls = [];
|
||
let m;
|
||
while ((m = re.exec(text))) {
|
||
let obj;
|
||
try { obj = JSON.parse(m[1]); } catch { continue; }
|
||
if (!obj || !obj.name) continue;
|
||
calls.push(mkCall(obj.name, obj.arguments ?? obj.parameters ?? {}));
|
||
}
|
||
if (calls.length) return { calls, clean: text.replace(re, '').trim() };
|
||
return parseInvokeStyle(text);
|
||
}
|
||
|
||
function sse(res, chunks) {
|
||
res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8',
|
||
'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
||
for (const c of chunks) res.write(`data: ${JSON.stringify(c)}\n\n`);
|
||
res.write('data: [DONE]\n\n');
|
||
res.end();
|
||
}
|
||
|
||
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' });
|
||
}
|
||
|
||
// OpenAI-совместимый чат с инструментами. Именно сюда ходит Hermes.
|
||
if (req.method === 'POST' && url.pathname === '/v1/chat/completions') {
|
||
const body = await readBody(req);
|
||
const tools = body.tools || [];
|
||
let prompt = renderMessages(body.messages);
|
||
if (tools.length) prompt = `${toolInstructions(tools)}\n\n${prompt}`;
|
||
if (!prompt.trim()) return json(res, 400, { error: { message: 'пустой запрос' } });
|
||
const imageBytes = imageOf(body.messages);
|
||
const started = Date.now();
|
||
console.log(`v1 запрос ${prompt.length} симв., инструментов ${tools.length}`);
|
||
const answer = await serial(() => track(() => ask({
|
||
prompt, imageBytes, timeout: cfg.timeout, log: m => console.log(` ${m}`),
|
||
})));
|
||
const { calls, clean } = tools.length ? parseToolCalls(answer) : { calls: [], clean: answer };
|
||
console.log(`v1 ответ ${answer.length} симв., вызовов ${calls.length}, за ${
|
||
Math.round((Date.now() - started) / 1000)} с`);
|
||
const model = body.model || MODEL;
|
||
const created = Math.floor(Date.now() / 1000);
|
||
const id = `chatcmpl-${created}`;
|
||
const message = calls.length
|
||
? { role: 'assistant', content: clean || null, tool_calls: calls }
|
||
: { role: 'assistant', content: answer };
|
||
const finish = calls.length ? 'tool_calls' : 'stop';
|
||
if (body.stream) {
|
||
const base = { id, object: 'chat.completion.chunk', created, model };
|
||
const delta = calls.length
|
||
? { role: 'assistant', content: clean || '', tool_calls: calls.map((c, i) => ({ index: i, ...c })) }
|
||
: { role: 'assistant', content: answer };
|
||
return sse(res, [
|
||
{ ...base, choices: [{ index: 0, delta, finish_reason: null }] },
|
||
{ ...base, choices: [{ index: 0, delta: {}, finish_reason: finish }] },
|
||
]);
|
||
}
|
||
return json(res, 200, {
|
||
id, object: 'chat.completion', created, model,
|
||
choices: [{ index: 0, message, finish_reason: finish }],
|
||
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
||
});
|
||
}
|
||
|
||
// Список моделей в формате OpenAI — некоторые клиенты дёргают его при старте.
|
||
if (req.method === 'GET' && url.pathname === '/v1/models') {
|
||
return json(res, 200, { object: 'list',
|
||
data: [{ id: MODEL, object: 'model', created: 0, owned_by: 'deepseek' }] });
|
||
}
|
||
|
||
// Эмбеддинги веб-чат не отдаёт в принципе. Честный отказ лучше пустого
|
||
// вектора: клиент увидит причину, а не молча испорченный поиск.
|
||
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();
|
||
}
|
||
}
|