255 lines
12 KiB
JavaScript
255 lines
12 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* filemode.js — режим «редактор файла» для сайта документации Nubes.
|
||
*
|
||
* Отличие от POST /_api/assist: там — справка по строгому шаблону, здесь —
|
||
* работа с содержимым файла юзера (обычно terraform.tfvars). Юзер вставляет
|
||
* содержимое файла + инструкцию; LLM возвращает ПОЛНЫЙ обновлённый файл,
|
||
* список изменений и предупреждения. Деплой (terraform plan/apply) юзер
|
||
* делает сам — этот режим ничего не деплоит и не хранит файл.
|
||
*
|
||
* Безопасность:
|
||
* - секреты сюда НЕ кладут — они живут в отдельном secrets.auto.tfvars
|
||
* (см. AGENTS.md); здесь допустимы только заглушки вида <token>;
|
||
* - логируем ТОЛЬКО размеры, содержимое файла — никогда;
|
||
* - лимит тела FILE_MAX_BODY (64 КБ по умолчанию).
|
||
*/
|
||
|
||
const { llmChat, jsonError } = require('./assist.js');
|
||
const { search, stripHtml } = require('./search.js');
|
||
const { fetchIndex, clientIp } = require('./orchestrator.js');
|
||
|
||
// Лимит на тело запроса (JSON {content, instruction}). terraform.tfvars — файл
|
||
// ПАРАМЕТРОВ: в примерах он ≤ ~2.5 КБ; 8 КБ даёт запас на комментарии. Больше —
|
||
// это «запихнул весь проект», такое отклоняем понятным сообщением.
|
||
const MAX_BODY = intEnv('FILE_MAX_BODY', 8192);
|
||
const MAX_TOKENS = intEnv('FILE_MAX_TOKENS', 4096); // бюджет ответа LLM (полный файл)
|
||
const TOO_BIG_MSG = 'слишком много данных: это файл параметров (terraform.tfvars), ' +
|
||
'а не весь проект. Лимит ' + MAX_BODY + ' байт.';
|
||
|
||
function intEnv(name, fallback) {
|
||
const raw = process.env[name];
|
||
if (raw === undefined || raw === null || raw === '') return fallback;
|
||
const n = Number.parseInt(raw, 10);
|
||
return Number.isFinite(n) ? n : fallback;
|
||
}
|
||
|
||
/**
|
||
* Системный промпт режима. Жёсткое требование — вернуть JSON с ПОЛНЫМ файлом
|
||
* (не фрагмент), чтобы юзер мог заменить файл целиком. Запрет выдумывать
|
||
* имена полей и значения: опора только на документацию Nubes в блоке ниже.
|
||
*/
|
||
const SYSTEM_PROMPT =
|
||
'Ты — ассистент Terraform-провайдера Nubes, редактор terraform.tfvars. ' +
|
||
'Пользователь прислал содержимое своего файла и инструкцию. ' +
|
||
'Верни СТРОГО JSON-объект вида {"content":"...","changes":["..."],"warnings":["..."]}. ' +
|
||
'content — ПОЛНЫЙ обновлённый файл (весь, не фрагмент), сохраняя комментарии и значения, ' +
|
||
'которые не менялись; changes — список внесённых изменений; warnings — предупреждения. ' +
|
||
'Используй ТОЛЬКО параметры из документации Nubes ниже; не выдумывай имён полей и значений. ' +
|
||
'Секреты/токены/пароли не трогай — они в отдельном файле; значения вида <token> оставляй как есть. ' +
|
||
'Отвечай на русском.';
|
||
|
||
/**
|
||
* Читает тело запроса со счётчиком байт; при превышении MAX_BODY обрывает поток.
|
||
*
|
||
* @param {import('http').IncomingMessage} req
|
||
* @returns {Promise<{ok:true, body:string}|{ok:false, code:number}>}
|
||
*/
|
||
function readBody(req) {
|
||
return new Promise((resolve) => {
|
||
const chunks = [];
|
||
let total = 0;
|
||
let settled = false;
|
||
const done = (r) => { if (!settled) { settled = true; resolve(r); } };
|
||
req.on('data', (c) => {
|
||
if (settled) return;
|
||
total += c.length;
|
||
if (total > MAX_BODY) {
|
||
req.destroy();
|
||
done({ ok: false, code: 413 });
|
||
return;
|
||
}
|
||
chunks.push(c);
|
||
});
|
||
req.on('end', () => done({ ok: true, body: Buffer.concat(chunks).toString('utf8') }));
|
||
req.on('error', () => done({ ok: false, code: 400 }));
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Извлекает JSON-объект из сырого ответа LLM (устойчиво к markdown-ограждениям).
|
||
*
|
||
* @param {string} raw
|
||
* @returns {object|null}
|
||
*/
|
||
function extractJson(raw) {
|
||
const s = String(raw ?? '').replace(/```(?:json)?/gi, '').replace(/```/g, '').trim();
|
||
const a = s.indexOf('{');
|
||
const b = s.lastIndexOf('}');
|
||
if (a === -1 || b <= a) return null;
|
||
try { return JSON.parse(s.slice(a, b + 1)); } catch { return null; }
|
||
}
|
||
|
||
/**
|
||
* Главный обработчик POST /<stand>/_api/file.
|
||
*
|
||
* @param {import('http').IncomingMessage} req
|
||
* @param {import('http').ServerResponse} res
|
||
* @param {object} config
|
||
* @param {string} config.stand
|
||
* @param {string} config.vmDocsBaseUrl
|
||
* @param {object} config.shared — разделяемые состояния (limiter, semaphore) из orchestrator.
|
||
*/
|
||
async function handleFile(req, res, config) {
|
||
const stand = config.stand;
|
||
const vmDocsBaseUrl = config.vmDocsBaseUrl;
|
||
const shared = config.shared;
|
||
|
||
// 1) Content-Type.
|
||
const ct = (req.headers['content-type'] || '').toLowerCase();
|
||
if (!ct.startsWith('application/json')) {
|
||
jsonError(res, 415, 415, 'unsupported media type');
|
||
return;
|
||
}
|
||
|
||
// 1б) Content-Length больше лимита — отказ БЕЗ чтения тела. Иначе при
|
||
// превышении в процессе чтения мы вынуждены оборвать поток (req.destroy()),
|
||
// и клиент получает обрыв соединения вместо чистого 413. См. assist.js.
|
||
const declaredLen = Number.parseInt(req.headers['content-length'], 10);
|
||
if (Number.isFinite(declaredLen) && declaredLen > MAX_BODY) {
|
||
jsonError(res, 413, 413, TOO_BIG_MSG);
|
||
return;
|
||
}
|
||
|
||
// 2) Тело с лимитом + разбор {content, instruction}.
|
||
const read = await readBody(req);
|
||
if (!read.ok) {
|
||
jsonError(res, read.code, read.code, read.code === 413 ? TOO_BIG_MSG : 'bad request body');
|
||
return;
|
||
}
|
||
let parsed;
|
||
try { parsed = JSON.parse(read.body); } catch {
|
||
jsonError(res, 400, 400, 'invalid json');
|
||
return;
|
||
}
|
||
const content = typeof parsed.content === 'string' ? parsed.content : '';
|
||
const instruction = typeof parsed.instruction === 'string' ? parsed.instruction.trim() : '';
|
||
if (!content.trim()) {
|
||
jsonError(res, 400, 400, 'content is required');
|
||
return;
|
||
}
|
||
|
||
// 3) Rate-limit (общий limiter с ассистентом; ключ — IP клиента).
|
||
const lim = shared.limiter.check(clientIp(req));
|
||
if (!lim.ok) {
|
||
jsonError(res, 429, 'rate_limited', 'too many requests', { 'Retry-After': String(lim.retryAfterSec) });
|
||
return;
|
||
}
|
||
|
||
// 4) Семафор (общий с ассистентом; сверх — 503 busy).
|
||
if (!shared.semaphore.tryAcquire()) {
|
||
jsonError(res, 503, 'busy', 'server is busy, retry later', { 'Retry-After': '5' });
|
||
return;
|
||
}
|
||
|
||
// Отмена вызова при закрытии соединения клиентом.
|
||
const controller = new AbortController();
|
||
const onClose = () => controller.abort();
|
||
req.on('close', onClose);
|
||
|
||
try {
|
||
// 5) Контекст из документации: ищем по инструкции (иначе по содержимому).
|
||
const query = instruction || content;
|
||
let docsBlock = '';
|
||
const index = await fetchIndex(stand, vmDocsBaseUrl);
|
||
if (index) {
|
||
const hits = search(index, query, { topK: 5 });
|
||
docsBlock = hits
|
||
.map((h, i) => `[${i + 1}] ${h.title}\n${stripHtml(h.text).slice(0, 1200)}`)
|
||
.join('\n\n');
|
||
}
|
||
if (!docsBlock) {
|
||
docsBlock = '(документация не найдена — используй только общие правила, не выдумывай параметры)';
|
||
}
|
||
|
||
const userContent =
|
||
'ДОКУМЕНТАЦИЯ:\n' + docsBlock +
|
||
'\n\nСОДЕРЖИМОЕ ФАЙЛА:\n' + content +
|
||
'\n\nИНСТРУКЦИЯ:\n' + (instruction || '(проверь и исправь ошибки)');
|
||
|
||
const out = await llmChat({
|
||
messages: [
|
||
{ role: 'system', content: SYSTEM_PROMPT },
|
||
{ role: 'user', content: userContent },
|
||
],
|
||
maxTokens: MAX_TOKENS,
|
||
temperature: 0.1,
|
||
responseFormat: { type: 'json_object' },
|
||
controller,
|
||
baseUrl: process.env.LLM_BASE_URL || '',
|
||
apiKey: process.env.LLM_API_KEY || '',
|
||
model: process.env.LLM_MODEL || 'gpt-oss-120b',
|
||
});
|
||
|
||
const obj = extractJson(out);
|
||
jsonOk(res, 200, {
|
||
content: (obj && typeof obj.content === 'string') ? obj.content : out,
|
||
changes: (obj && Array.isArray(obj.changes)) ? obj.changes : [],
|
||
warnings: (obj && Array.isArray(obj.warnings)) ? obj.warnings : [],
|
||
meta: {
|
||
model: process.env.LLM_MODEL || 'gpt-oss-120b',
|
||
input_bytes: content.length,
|
||
},
|
||
});
|
||
logFile('ok', stand, content.length, instruction.length);
|
||
} catch (err) {
|
||
const code = err && err.code ? err.code : 'internal_error';
|
||
jsonError(res, statusFor(code), code, err && err.message ? err.message : 'internal error');
|
||
logFile('error', stand, content.length, instruction.length);
|
||
} finally {
|
||
try { shared.semaphore.release(); } catch { /* не должен случиться */ }
|
||
req.removeListener('close', onClose);
|
||
}
|
||
}
|
||
|
||
/** Отправка JSON-ответа 200 (Content-Length считается явно). */
|
||
function jsonOk(res, status, obj) {
|
||
const body = JSON.stringify(obj);
|
||
if (!res.headersSent) {
|
||
res.writeHead(status, {
|
||
'Content-Type': 'application/json; charset=utf-8',
|
||
'Content-Length': Buffer.byteLength(body),
|
||
});
|
||
}
|
||
res.end(body);
|
||
}
|
||
|
||
/** Маппинг кодов ошибок LLM на HTTP-статусы (аналогично orchestrator.mapPipelineError). */
|
||
function statusFor(code) {
|
||
switch (code) {
|
||
case 'llm_timeout': return 504;
|
||
case 'bad_llm_output':
|
||
case 'empty_content': return 502;
|
||
case 'aborted': return 499;
|
||
case 'rate_limited': return 503;
|
||
default: return 502;
|
||
}
|
||
}
|
||
|
||
/** Однострочный JSON-лог БЕЗ содержимого файла (только размеры). */
|
||
function logFile(status, stand, contentLen, instrLen) {
|
||
try {
|
||
process.stdout.write(JSON.stringify({
|
||
t: new Date().toISOString(),
|
||
mode: 'file',
|
||
status,
|
||
stand,
|
||
content_len: contentLen,
|
||
instr_len: instrLen,
|
||
}) + '\n');
|
||
} catch { /* лог не должен ронять */ }
|
||
}
|
||
|
||
module.exports = { handleFile };
|