'use strict'; /** * filemode.js — режим «редактор файла» для сайта документации Nubes. * * Отличие от POST /_api/assist: там — справка по строгому шаблону, здесь — * работа с содержимым файла юзера (обычно terraform.tfvars). Юзер вставляет * содержимое файла + инструкцию; LLM возвращает ПОЛНЫЙ обновлённый файл, * список изменений и предупреждения. Деплой (terraform plan/apply) юзер * делает сам — этот режим ничего не деплоит и не хранит файл. * * Безопасность: * - секреты сюда НЕ кладут — они живут в отдельном secrets.auto.tfvars * (см. AGENTS.md); здесь допустимы только заглушки вида ; * - логируем ТОЛЬКО размеры, содержимое файла — никогда; * - лимит тела 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 ниже; не выдумывай имён полей и значений. ' + 'Секреты/токены/пароли не трогай — они в отдельном файле; значения вида оставляй как есть. ' + 'Отвечай на русском.'; /** * Читает тело запроса со счётчиком байт; при превышении 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 //_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 };