From 1fcfa4f070c810fb51b5cd5e9dd224a182a66caf Mon Sep 17 00:00:00 2001 From: Repinoid Date: Mon, 12 Oct 2026 08:40:05 +0300 Subject: [PATCH] =?UTF-8?q?feat(assist):=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC?= =?UTF-8?q?=20'=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=BE=D1=80=20=D1=84?= =?UTF-8?q?=D0=B0=D0=B9=D0=BB=D0=B0'=20POST=20//=5Fapi/file=20(terr?= =?UTF-8?q?aform.tfvars)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- filemode.js | 240 ++++++++++++++++++++++++++++++++++++++++++++++++++++ server.js | 24 ++++++ 2 files changed, 264 insertions(+) create mode 100644 filemode.js diff --git a/filemode.js b/filemode.js new file mode 100644 index 0000000..cfb59e8 --- /dev/null +++ b/filemode.js @@ -0,0 +1,240 @@ +'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'); + +const MAX_BODY = intEnv('FILE_MAX_BODY', 65536); // макс. размер файла юзера, байт +const MAX_TOKENS = intEnv('FILE_MAX_TOKENS', 4096); // бюджет ответа LLM (полный файл) + +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; + } + + // 2) Тело с лимитом + разбор {content, instruction}. + const read = await readBody(req); + if (!read.ok) { + jsonError(res, read.code, read.code, 'body too large'); + 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 }; diff --git a/server.js b/server.js index d5e4a1f..59f6869 100644 --- a/server.js +++ b/server.js @@ -83,6 +83,15 @@ try { assistShared = null; } +// Режим «редактор файла» (terraform.tfvars): лениво грузим, не роняем статику. +let fileHandler = null; +try { + const filemode = require('./filemode.js'); + fileHandler = filemode.handleFile; +} catch (_) { + fileHandler = null; +} + const server = http.createServer((req, res) => { // POST //_api/assist — ветка AI-ассистента (до проверки 405). if (req.method === 'POST') { @@ -101,6 +110,21 @@ const server = http.createServer((req, res) => { }); return; } + + const fileMatch = path.match(/^\/([A-Za-z0-9._-]+)\/_api\/file$/); + if (fileMatch && fileHandler && assistShared) { + const stand = fileMatch[1]; + fileHandler(req, res, { stand, vmDocsBaseUrl: VM_DOCS_BASE_URL, shared: assistShared }) + .catch(() => { + try { + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ error: 'internal_error', message: 'file mode failed' })); + } + } catch (_) { } + }); + return; + } } if (req.method !== 'GET' && req.method !== 'HEAD') {