diff --git a/server.mjs b/server.mjs
index f10f293..b90bdee 100644
--- a/server.mjs
+++ b/server.mjs
@@ -136,6 +136,132 @@ export function imageOf(messages) {
return null;
}
+// ──────────────────── OpenAI /v1 с эмуляцией вызова инструментов ────────────
+// Агенты (Hermes и пр.) говорят по OpenAI /v1/chat/completions и передают
+// инструменты в поле tools, ожидая обратно tool_calls. У веб-чата DeepSeek нет
+// ни ролей, ни функций — поэтому инструменты вклеиваем в текст промпта, просим
+// модель звать их блоком {...}, а ответ разбираем обратно
+// в tool_calls. Ради этого вся затея: DeepSeek держит формат надёжно, а слабые
+// локальные модели через настоящий tool-calling его ломали.
+
+export const TOOL_OPEN = '';
+export const TOOL_CLOSE = '';
+
+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":"","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 не всегда слушается инструкции про {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 };
+}
+
+// Достаём вызовы инструментов из ответа модели: сперва наш {json},
+// затем, если пусто, — invoke/parameter-формат DeepSeek.
+export function parseToolCalls(text) {
+ const re = /\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',
@@ -264,6 +390,52 @@ const server = createServer(async (req, res) => {
{ ...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') {