feat(assist): режим 'редактор файла' POST /<stand>/_api/file (terraform.tfvars)
This commit is contained in:
+240
@@ -0,0 +1,240 @@
|
|||||||
|
'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');
|
||||||
|
|
||||||
|
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 ниже; не выдумывай имён полей и значений. ' +
|
||||||
|
'Секреты/токены/пароли не трогай — они в отдельном файле; значения вида <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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 };
|
||||||
@@ -83,6 +83,15 @@ try {
|
|||||||
assistShared = null;
|
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) => {
|
const server = http.createServer((req, res) => {
|
||||||
// POST /<stand>/_api/assist — ветка AI-ассистента (до проверки 405).
|
// POST /<stand>/_api/assist — ветка AI-ассистента (до проверки 405).
|
||||||
if (req.method === 'POST') {
|
if (req.method === 'POST') {
|
||||||
@@ -101,6 +110,21 @@ const server = http.createServer((req, res) => {
|
|||||||
});
|
});
|
||||||
return;
|
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') {
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||||
|
|||||||
Reference in New Issue
Block a user