v1.0.159: JS extracted to app.js on VM — Lucee is now HTML shell only (191 lines)
This commit is contained in:
+644
@@ -0,0 +1,644 @@
|
|||||||
|
// Contracts App — весь JS на VM (contracts.kube5s.ru)
|
||||||
|
// Lucee: только домен + index.cfm-скелет
|
||||||
|
var VM_API = 'https://contracts.kube5s.ru';
|
||||||
|
var UPLOAD_URL = VM_API + '/upload';
|
||||||
|
var CONVERT_URL = VM_API + '/convert-doc';
|
||||||
|
var UNZIP_URL = VM_API + '/unzip-upload';
|
||||||
|
var UNZIP_URL = 'https://contracts.kube5s.ru/unzip-upload';
|
||||||
|
var SITE_URL = ''; // same origin for api calls
|
||||||
|
|
||||||
|
var fileInput = document.getElementById('fileInput');
|
||||||
|
var fileTable = document.getElementById('fileTable');
|
||||||
|
var fileQueue = [];
|
||||||
|
var contractId = null;
|
||||||
|
|
||||||
|
function formatSize(bytes) {
|
||||||
|
if (!bytes || bytes === 0) return '—';
|
||||||
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||||
|
return (bytes / 1048576).toFixed(1) + ' MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(ts) {
|
||||||
|
if (!ts) return '—';
|
||||||
|
var d = new Date(ts);
|
||||||
|
return d.toLocaleDateString('ru-RU') + ' ' + d.toLocaleTimeString('ru-RU', {hour:'2-digit',minute:'2-digit'});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
if (fileQueue.length === 0) {
|
||||||
|
fileTable.innerHTML = '<tr class="empty-row"><td colspan="7">Нет файлов — выберите .docx / .pdf</td></tr>';
|
||||||
|
} else {
|
||||||
|
fileTable.innerHTML = fileQueue.map(function(f, i) {
|
||||||
|
var isFirst = (i === 0);
|
||||||
|
var isLast = (i === fileQueue.length - 1);
|
||||||
|
var arrows = '<button class="arrow-btn" onclick="moveUp(' + i + ')" ' + (isFirst ? 'disabled' : '') + ' title="Вверх">▲</button>' +
|
||||||
|
'<button class="arrow-btn" onclick="moveDown(' + i + ')" ' + (isLast ? 'disabled' : '') + ' title="Вниз">▼</button>';
|
||||||
|
return '<tr id="row_' + i + '" class="' + (isFirst ? 'first-row' : '') + '">' +
|
||||||
|
'<td class="name-cell">' + f.name + '</td>' +
|
||||||
|
'<td style="font-size:12px;color:var(--muted);">' + formatDate(f.lastModified) + '</td>' +
|
||||||
|
'<td class="num-cell" style="font-size:12px;color:var(--muted);">' + formatSize(f.size) + '</td>' +
|
||||||
|
'<td class="status-cell">' + (f.status || '') + '</td>' +
|
||||||
|
'<td style="text-align:center;">' + arrows + '</td>' +
|
||||||
|
'<td><button class="info-btn' + (f.doc_id ? ' visible' : '') + '" onclick="showText(' + i + ')" title="Текст / Распарсено"><i data-lucide="file-text" style="width:16px;height:16px;"></i></button></td>' +
|
||||||
|
'<td><button class="remove-btn" onclick="removeFile(' + i + ')" title="Удалить"><i data-lucide="trash-2" style="width:16px;height:16px;"></i></button></td>' +
|
||||||
|
'</tr>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
lucide.createIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFile(i) {
|
||||||
|
fileQueue.splice(i, 1);
|
||||||
|
if (fileQueue.length === 0) contractId = null;
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
window.removeFile = removeFile;
|
||||||
|
|
||||||
|
function moveUp(i) {
|
||||||
|
if (i <= 0) return;
|
||||||
|
var tmp = fileQueue[i]; fileQueue[i] = fileQueue[i-1]; fileQueue[i-1] = tmp;
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
function moveDown(i) {
|
||||||
|
if (i >= fileQueue.length - 1) return;
|
||||||
|
var tmp = fileQueue[i]; fileQueue[i] = fileQueue[i+1]; fileQueue[i+1] = tmp;
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
window.moveUp = moveUp;
|
||||||
|
window.moveDown = moveDown;
|
||||||
|
|
||||||
|
// ── О сервисе ────────────────────────────────────────────────
|
||||||
|
function openAbout() {
|
||||||
|
document.getElementById('aboutModalOverlay').classList.add('open');
|
||||||
|
}
|
||||||
|
function closeAbout(e) {
|
||||||
|
if (e && e.target !== document.getElementById('aboutModalOverlay')) return;
|
||||||
|
document.getElementById('aboutModalOverlay').classList.remove('open');
|
||||||
|
}
|
||||||
|
window.openAbout = openAbout;
|
||||||
|
window.closeAbout = closeAbout;
|
||||||
|
|
||||||
|
// ── Автозагрузка при выборе файлов ──────────────────────────
|
||||||
|
fileInput.addEventListener('change', async function() {
|
||||||
|
var newFiles = Array.from(fileInput.files);
|
||||||
|
if (newFiles.length === 0) return;
|
||||||
|
|
||||||
|
fileInput.disabled = true;
|
||||||
|
|
||||||
|
for (var i = 0; i < newFiles.length; i++) {
|
||||||
|
var f = newFiles[i];
|
||||||
|
var isZip = f.name.toLowerCase().endsWith('.zip');
|
||||||
|
|
||||||
|
if (isZip) {
|
||||||
|
// ZIP: показать временную строку, распаковать, убрать
|
||||||
|
var zipEntry = { name: f.name, lastModified: f.lastModified, size: f.size, status: '⏳ распаковка...' };
|
||||||
|
fileQueue.push(zipEntry);
|
||||||
|
var zipIdx = fileQueue.length - 1;
|
||||||
|
renderTable();
|
||||||
|
|
||||||
|
try {
|
||||||
|
var zipResp = await new Promise(function(resolve, reject) {
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', UNZIP_URL);
|
||||||
|
xhr.responseType = 'json';
|
||||||
|
xhr.onload = function() { resolve(xhr.response); };
|
||||||
|
xhr.onerror = function() { reject(new Error('Сеть')); };
|
||||||
|
xhr.ontimeout = function() { reject(new Error('Таймаут')); };
|
||||||
|
xhr.timeout = 60000;
|
||||||
|
xhr.send(f);
|
||||||
|
});
|
||||||
|
var zipData = zipResp;
|
||||||
|
if (!zipData.ok || !zipData.files) throw new Error('unzip failed');
|
||||||
|
|
||||||
|
// Убрать строку ZIP из таблицы
|
||||||
|
fileQueue.splice(zipIdx, 1);
|
||||||
|
renderTable();
|
||||||
|
|
||||||
|
// Добавить файлы из архива, пропуская дубликаты
|
||||||
|
for (var zi = 0; zi < zipData.files.length; zi++) {
|
||||||
|
var zf = zipData.files[zi];
|
||||||
|
if (zf.error) continue; // пропускаем ошибки
|
||||||
|
|
||||||
|
// Проверить дубликат по имени
|
||||||
|
var dup = -1;
|
||||||
|
for (var dj = 0; dj < fileQueue.length; dj++) {
|
||||||
|
if (fileQueue[dj].name === zf.filename) { dup = dj; break; }
|
||||||
|
}
|
||||||
|
if (dup >= 0) continue; // уже есть — не добавляем
|
||||||
|
|
||||||
|
var zEntry = {
|
||||||
|
name: zf.filename, size: zf.size,
|
||||||
|
doc_id: zf.doc_id, uploaded: true,
|
||||||
|
status: '⏳ парсинг...'
|
||||||
|
};
|
||||||
|
fileQueue.push(zEntry);
|
||||||
|
var zIdx = fileQueue.length - 1;
|
||||||
|
if (!contractId && zf.contract_id) contractId = zf.contract_id;
|
||||||
|
renderTable();
|
||||||
|
|
||||||
|
// Авто-парсинг: VM Python парсит ВСЁ
|
||||||
|
var zpr = zf.parsed;
|
||||||
|
if (zpr && zpr.status === 'parsed') {
|
||||||
|
fileQueue[zIdx].parsed = true;
|
||||||
|
fileQueue[zIdx].status = '<span class="status-ok">✓ ' + zpr.element_count + ' эл.</span>';
|
||||||
|
} else if (zpr && zpr.status === 'error') {
|
||||||
|
fileQueue[zIdx].status = '<span class="status-err">✗ ' + (zpr.error || 'ошибка парсинга') + '</span>';
|
||||||
|
} else {
|
||||||
|
fileQueue[zIdx].status = '<span class="status-err">✗ Неизвестная ошибка</span>';
|
||||||
|
}
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
} catch(ze) {
|
||||||
|
fileQueue[zipIdx].status = '<span class="status-err">✗ ZIP: ' + ze.message + '</span>';
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
continue; // ZIP обработан, переходим к следующему файлу
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обычный файл (не ZIP)
|
||||||
|
var entry = { name: f.name, lastModified: f.lastModified, size: f.size, file: f, status: '↑ 0%' };
|
||||||
|
var existingIdx = -1;
|
||||||
|
for (var j = 0; j < fileQueue.length; j++) {
|
||||||
|
if (fileQueue[j].name === f.name) { existingIdx = j; break; }
|
||||||
|
}
|
||||||
|
var rowIdx;
|
||||||
|
if (existingIdx >= 0) {
|
||||||
|
fileQueue[existingIdx] = entry;
|
||||||
|
rowIdx = existingIdx;
|
||||||
|
} else {
|
||||||
|
fileQueue.push(entry);
|
||||||
|
rowIdx = fileQueue.length - 1;
|
||||||
|
}
|
||||||
|
renderTable();
|
||||||
|
|
||||||
|
try {
|
||||||
|
var resp = await uploadFile(f, function(pct) {
|
||||||
|
fileQueue[rowIdx].status = '↑ ' + pct + '%';
|
||||||
|
renderTable();
|
||||||
|
});
|
||||||
|
// Python API returns lowercase keys (doc_id, contract_id, parsed)
|
||||||
|
if (resp && resp.contract_id) contractId = resp.contract_id;
|
||||||
|
fileQueue[rowIdx].doc_id = resp.doc_id;
|
||||||
|
fileQueue[rowIdx].status = '<span class="status-ok">✓</span>';
|
||||||
|
fileQueue[rowIdx].uploaded = true;
|
||||||
|
|
||||||
|
// Авто-парсинг: VM Python парсит ВСЁ (PDF + DOCX + DOC)
|
||||||
|
var t0 = Date.now();
|
||||||
|
var pr = resp.parsed;
|
||||||
|
if (pr && pr.status === 'parsed') {
|
||||||
|
var elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
||||||
|
fileQueue[rowIdx].parsed = true;
|
||||||
|
fileQueue[rowIdx].parseInfo = pr;
|
||||||
|
fileQueue[rowIdx].status = '<span class="status-ok">✓ ' + pr.element_count + ' эл. (' + elapsed + 'с)</span>';
|
||||||
|
} else if (pr && pr.status === 'error') {
|
||||||
|
fileQueue[rowIdx].status = '<span class="status-err">✗ ' + (pr.error || 'ошибка парсинга') + '</span>';
|
||||||
|
fileQueue[rowIdx].parseInfo = pr;
|
||||||
|
} else {
|
||||||
|
fileQueue[rowIdx].status = '<span class="status-err">✗ Неизвестная ошибка</span>';
|
||||||
|
}
|
||||||
|
} catch(err) {
|
||||||
|
fileQueue[rowIdx].status = '<span class="status-err">✗ ' + err.message + '</span>';
|
||||||
|
}
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshSupps();
|
||||||
|
|
||||||
|
fileInput.value = '';
|
||||||
|
fileInput.disabled = false;
|
||||||
|
// Показать кнопки LLM после авто-парсинга
|
||||||
|
var llmBtn = document.getElementById('llmBtn');
|
||||||
|
llmBtn.style.display = 'flex';
|
||||||
|
llmBtn.disabled = false;
|
||||||
|
lucide.createIcons();
|
||||||
|
});
|
||||||
|
|
||||||
|
function uploadFile(file, onProgress) {
|
||||||
|
return new Promise(function(resolve, reject) {
|
||||||
|
// .doc → конвертация в docx
|
||||||
|
var isDoc = file.name.toLowerCase().endsWith('.doc') && !file.name.toLowerCase().endsWith('.docx');
|
||||||
|
var uploadFile = file;
|
||||||
|
var uploadName = file.name;
|
||||||
|
|
||||||
|
function doUpload() {
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('files', uploadFile, uploadName);
|
||||||
|
if (contractId) fd.append('contract_id', contractId);
|
||||||
|
xhr.open('POST', UPLOAD_URL);
|
||||||
|
xhr.upload.onprogress = function(e) {
|
||||||
|
if (e.lengthComputable && onProgress) onProgress(Math.round(e.loaded / e.total * 100));
|
||||||
|
};
|
||||||
|
xhr.onload = function() {
|
||||||
|
try {
|
||||||
|
var r = JSON.parse(xhr.responseText);
|
||||||
|
if (r.ok) resolve(r);
|
||||||
|
else reject(new Error(r.error || 'Неизвестная ошибка'));
|
||||||
|
} catch(e) { reject(new Error('Некорректный ответ')); }
|
||||||
|
};
|
||||||
|
xhr.onerror = function() { reject(new Error('Сеть')); };
|
||||||
|
xhr.ontimeout = function() { reject(new Error('Таймаут')); };
|
||||||
|
xhr.timeout = 180000;
|
||||||
|
xhr.send(fd);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDoc) {
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', CONVERT_URL);
|
||||||
|
xhr.responseType = 'blob';
|
||||||
|
xhr.onload = function() {
|
||||||
|
if (xhr.status === 200 && xhr.response.size > 100) {
|
||||||
|
uploadFile = xhr.response;
|
||||||
|
uploadName = file.name.replace(/\.doc$/i, '.docx');
|
||||||
|
doUpload();
|
||||||
|
} else {
|
||||||
|
reject(new Error('Конвертация .doc'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.onerror = function() { reject(new Error('Конвертер')); };
|
||||||
|
xhr.send(file);
|
||||||
|
} else {
|
||||||
|
doUpload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LLM: Сравнить (Event Sourcing) ────────────────────────
|
||||||
|
document.getElementById('llmBtn').addEventListener('click', function() {
|
||||||
|
if (!contractId) return;
|
||||||
|
var btn = this;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner"></span> Сравнение...';
|
||||||
|
|
||||||
|
var diffCard = document.getElementById('diffCard');
|
||||||
|
var diffBody = document.getElementById('diffBody');
|
||||||
|
var diffStatus = document.getElementById('diffStatus');
|
||||||
|
diffCard.style.display = 'block';
|
||||||
|
diffBody.innerHTML = '';
|
||||||
|
diffStatus.textContent = '⏳ 0.0с';
|
||||||
|
|
||||||
|
var llmStart = Date.now();
|
||||||
|
var timerInterval = setInterval(function() {
|
||||||
|
diffStatus.textContent = '⏳ ' + ((Date.now() - llmStart) / 1000).toFixed(1) + 'с';
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
var order = fileQueue.map(function(f) { return f.supp_id; }).filter(Boolean).join(',');
|
||||||
|
var es = new EventSource('https://contracts.kube5s.ru/process-v2?contract_id=' + contractId + (order ? '&order=' + encodeURIComponent(order) : ''));
|
||||||
|
var sections = {}; // supplement_id → DOM element
|
||||||
|
|
||||||
|
es.onmessage = function(e) {
|
||||||
|
var d = JSON.parse(e.data);
|
||||||
|
|
||||||
|
if (d.type === 'extract_start') {
|
||||||
|
var sec = document.createElement('div');
|
||||||
|
sec.style.cssText = 'margin-top:8px;border:1px solid var(--brand-gray);border-radius:6px;overflow:hidden;';
|
||||||
|
sec.innerHTML = '<div class="diff-section-header" style="padding:8px 12px;background:var(--brand-grey-light);cursor:pointer;font-size:13px;font-weight:600;">⏳ ' + d.filename + '</div><div class="diff-section-body" style="display:none;padding:8px 12px;"></div>';
|
||||||
|
sec.querySelector('.diff-section-header').addEventListener('click', function() {
|
||||||
|
var body = sec.querySelector('.diff-section-body');
|
||||||
|
body.style.display = body.style.display === 'none' ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
diffBody.appendChild(sec);
|
||||||
|
sections[d.supplement_id] = sec;
|
||||||
|
}
|
||||||
|
else if (d.type === 'llm_done') {
|
||||||
|
var sec = sections[d.supplement_id];
|
||||||
|
if (sec) {
|
||||||
|
sec.querySelector('.diff-section-header').innerHTML = '✓ ' + d.filename + ' — ' + d.ops_count + ' оп., ' + d.mode + ' (' + d.time_s + 'с)';
|
||||||
|
sec.querySelector('.diff-section-header').style.color = 'var(--green)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (d.type === 'applied') {
|
||||||
|
var sec = sections[d.supplement_id];
|
||||||
|
if (sec && d.ops && d.ops.length > 0) {
|
||||||
|
var s = d.summary || {};
|
||||||
|
var body = sec.querySelector('.diff-section-body');
|
||||||
|
var html = '<div style="font-size:12px;margin-bottom:6px;">+' + (s.added||0) + ' ~' + (s.updated||0) + ' -' + (s.deleted||0) + ' ?' + (s.unresolved||0) + '</div>';
|
||||||
|
html += '<div class="table-wrap"><table style="font-size:11px;"><thead><tr><th>Действие</th><th>Услуга</th><th>Цена</th><th>Кол-во</th><th>Сумма</th><th>Дата</th></tr></thead><tbody>';
|
||||||
|
d.ops.forEach(function(op) {
|
||||||
|
var action = op.action;
|
||||||
|
var cls = action === 'ADD' ? 'diff-added' : action === 'DELETE' ? 'diff-deleted' : action === 'UPDATE' ? 'diff-changed' : '';
|
||||||
|
var nr = op.new_row || {};
|
||||||
|
var name = nr.name || '';
|
||||||
|
var price = nr.price != null ? nr.price : '';
|
||||||
|
var qty = nr.qty != null ? nr.qty : '';
|
||||||
|
var sum = nr.sum != null ? nr.sum : '';
|
||||||
|
var ds = nr.date_start || '';
|
||||||
|
html += '<tr class="' + cls + '"><td>' + action + '</td><td>' + name + '</td><td class="num-cell">' + price + '</td><td class="num-cell">' + qty + '</td><td class="num-cell">' + sum + '</td><td>' + ds + '</td></tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
body.innerHTML = html;
|
||||||
|
body.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (d.type === 'extract_error' || d.type === 'apply_error') {
|
||||||
|
diffBody.innerHTML += '<div style="font-size:12px;color:var(--destructive);">✗ ' + d.filename + ': ' + d.error + '</div>';
|
||||||
|
}
|
||||||
|
else if (d.type === 'done') {
|
||||||
|
clearInterval(timerInterval);
|
||||||
|
es.close();
|
||||||
|
diffStatus.textContent = '✓ ' + d.total_time_s + 'с';
|
||||||
|
btn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px;"></i> ✓ Готово';
|
||||||
|
if (d.total_unresolved > 0) {
|
||||||
|
diffBody.innerHTML += '<div style="margin-top:8px;color:#eab308;">⚠ ' + d.total_unresolved + ' неразрешённых строк — <a href="/resolve.cfm?contract_id=' + contractId + '">разобрать</a></div>';
|
||||||
|
}
|
||||||
|
document.getElementById('chatCard').style.display = 'block';
|
||||||
|
lucide.createIcons();
|
||||||
|
}
|
||||||
|
else if (d.type === 'error') {
|
||||||
|
clearInterval(timerInterval);
|
||||||
|
es.close();
|
||||||
|
diffBody.innerHTML += '<div style="color:var(--destructive);margin-top:8px;">✗ ' + d.message + '</div>';
|
||||||
|
btn.innerHTML = '<i data-lucide="scale" style="width:16px;height:16px;"></i> Сравнить';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
es.onerror = function() {
|
||||||
|
clearInterval(timerInterval);
|
||||||
|
es.close();
|
||||||
|
if (diffBody.innerHTML === '') {
|
||||||
|
diffBody.innerHTML = '<div style="color:var(--destructive);">✗ Ошибка соединения</div>';
|
||||||
|
}
|
||||||
|
btn.innerHTML = '<i data-lucide="scale" style="width:16px;height:16px;"></i> Сравнить';
|
||||||
|
btn.disabled = false;
|
||||||
|
lucide.createIcons();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Обновить supplement_id/type для radio ──────────────────
|
||||||
|
async function refreshSupps() {
|
||||||
|
if (!contractId) { console.log('refreshSupps: no contractId'); return; }
|
||||||
|
try {
|
||||||
|
var resp = await fetch(VM_API + '/api/supplements?contract_id=' + contractId);
|
||||||
|
var data = await resp.json();
|
||||||
|
console.log('refreshSupps: data=', data);
|
||||||
|
if (data.ok && data.supplements) {
|
||||||
|
data.supplements.forEach(function(supp) {
|
||||||
|
console.log('refreshSupps: supp=', supp);
|
||||||
|
for (var i = 0; i < fileQueue.length; i++) {
|
||||||
|
if (fileQueue[i].doc_id === supp.document_id) {
|
||||||
|
fileQueue[i].supp_id = supp.id;
|
||||||
|
fileQueue[i].supp_type = supp.type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
} catch(e) { console.log('refreshSupps error:', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Чат ──────────────────────────────────────────────────────
|
||||||
|
document.getElementById('chatSend').addEventListener('click', function() {
|
||||||
|
var input = document.getElementById('chatInput');
|
||||||
|
var q = input.value.trim();
|
||||||
|
if (!q || !contractId) return;
|
||||||
|
var msgs = document.getElementById('chatMessages');
|
||||||
|
msgs.innerHTML += '<div style="color:var(--brand-primary);margin-top:4px;"><strong>Вы:</strong> ' + q + '</div>';
|
||||||
|
input.value = '';
|
||||||
|
input.disabled = true;
|
||||||
|
document.getElementById('chatSend').disabled = true;
|
||||||
|
msgs.innerHTML += '<div style="color:var(--muted);margin-top:2px;">⏳ Думаю...</div>';
|
||||||
|
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('question', q);
|
||||||
|
fetch('/chat.cfm?contract_id=' + contractId, { method: 'POST', body: fd })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(d) {
|
||||||
|
msgs.lastChild.remove();
|
||||||
|
msgs.innerHTML += '<div style="margin-top:2px;"><strong>Ответ:</strong> ' + (d.OK ? d.ANSWER : (d.ERROR || '—')) + '</div>';
|
||||||
|
input.disabled = false;
|
||||||
|
document.getElementById('chatSend').disabled = false;
|
||||||
|
input.focus();
|
||||||
|
}).catch(function() {
|
||||||
|
msgs.lastChild.remove();
|
||||||
|
msgs.innerHTML += '<div style="color:var(--destructive);margin-top:2px;">Ошибка сети</div>';
|
||||||
|
input.disabled = false;
|
||||||
|
document.getElementById('chatSend').disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.getElementById('chatInput').addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Enter') document.getElementById('chatSend').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
function closeModal(e) {
|
||||||
|
if (e && e.target !== document.getElementById('modalOverlay')) return;
|
||||||
|
document.querySelector('.modal').classList.remove('wide');
|
||||||
|
document.getElementById('modalOverlay').classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showText(i) {
|
||||||
|
var f = fileQueue[i];
|
||||||
|
var docId = f.doc_id;
|
||||||
|
document.getElementById('modalTitle').textContent = f.name;
|
||||||
|
document.getElementById('modalBody').innerHTML = '<span style="color:var(--muted);">Загрузка...</span>';
|
||||||
|
var modal = document.querySelector('.modal');
|
||||||
|
modal.classList.add('wide');
|
||||||
|
document.getElementById('modalOverlay').classList.add('open');
|
||||||
|
|
||||||
|
var leftHtml = '<span style="color:var(--muted);">Загрузка...</span>';
|
||||||
|
var rightHtml = '';
|
||||||
|
|
||||||
|
if (docId) {
|
||||||
|
try {
|
||||||
|
var qResp = await fetch(VM_API + '/api/documents/' + docId);
|
||||||
|
var qData = await qResp.json();
|
||||||
|
var elements = [];
|
||||||
|
if (qData.ok && qData.elements_json) {
|
||||||
|
var ej = qData.elements_json;
|
||||||
|
if (typeof ej === 'object' && ej.Value) ej = ej.Value;
|
||||||
|
if (typeof ej === 'string') elements = JSON.parse(ej);
|
||||||
|
else elements = ej;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Общий textify для обеих панелей
|
||||||
|
var textLines = [];
|
||||||
|
elements.forEach(function(el) {
|
||||||
|
var etype = el.type || el.TYPE || '?';
|
||||||
|
if (etype === 'paragraph') {
|
||||||
|
textLines.push(el.text || el.TEXT || '');
|
||||||
|
} else if (etype === 'table') {
|
||||||
|
var rows = el.rows || el.ROWS || [];
|
||||||
|
if (rows.length > 0) {
|
||||||
|
var ncols = (rows[0]||[]).length;
|
||||||
|
textLines.push('--- Таблица ' + rows.length + '×' + ncols + ' ---');
|
||||||
|
rows.forEach(function(row) {
|
||||||
|
var cells = (row||[]).map(function(c){ return String(c||'').replace(/\n/g,' ').replace(/\|/g,'\\|'); });
|
||||||
|
textLines.push('| ' + cells.join(' | ') + ' |');
|
||||||
|
});
|
||||||
|
textLines.push('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
var textify = textLines.join('\n').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
|
||||||
|
// Левая: сырой текст (textify)
|
||||||
|
leftHtml = '<pre>' + textify + '</pre>';
|
||||||
|
|
||||||
|
// Правая: статистика + textify
|
||||||
|
rightHtml += '<div class="kv"><span class="k">Размер</span><span class="v">' + formatSize(f.size) + '</span></div>';
|
||||||
|
rightHtml += '<div class="kv"><span class="k">Изменён</span><span class="v">' + formatDate(f.lastModified) + '</span></div>';
|
||||||
|
if (f.parseInfo) {
|
||||||
|
var d = f.parseInfo;
|
||||||
|
rightHtml += '<div class="kv"><span class="k">Элементов</span><span class="v">' + (d.ELEMENT_COUNT||0) + '</span></div>';
|
||||||
|
rightHtml += '<div class="kv"><span class="k">Параграфов</span><span class="v">' + (d.PARAGRAPHS||0) + '</span></div>';
|
||||||
|
rightHtml += '<div class="kv"><span class="k">Таблиц</span><span class="v">' + (d.TABLES||0) + '</span></div>';
|
||||||
|
rightHtml += '<div class="kv"><span class="k">Строк таблиц</span><span class="v">' + (d.TABLE_ROWS||0) + '</span></div>';
|
||||||
|
if (d.PAGES) rightHtml += '<div class="kv"><span class="k">Страниц</span><span class="v">' + d.PAGES + '</span></div>';
|
||||||
|
if (d.PARSE_TIME_MS) {
|
||||||
|
rightHtml += '<div class="kv"><span class="k">Время парсинга</span><span class="v">' + (d.PARSE_TIME_MS > 1000 ? (d.PARSE_TIME_MS/1000).toFixed(1) + ' с' : d.PARSE_TIME_MS + ' мс') + '</span></div>';
|
||||||
|
}
|
||||||
|
if (d.TEXT_LENGTH) rightHtml += '<div class="kv"><span class="k">Символов</span><span class="v">' + d.TEXT_LENGTH.toLocaleString() + '</span></div>';
|
||||||
|
if (d.ERRORS && d.ERRORS.length > 0) {
|
||||||
|
rightHtml += '<div style="margin-top:8px;color:var(--destructive);font-weight:600;">Ошибки:</div>';
|
||||||
|
rightHtml += '<pre>' + d.ERRORS.join('\n') + '</pre>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rightHtml += '<div style="margin-top:10px;font-weight:600;font-size:12px;">Все элементы:</div>';
|
||||||
|
rightHtml += '<pre>' + textify + '</pre>';
|
||||||
|
if (f.supp_id) {
|
||||||
|
rightHtml += '<div class="kv"><span class="k">Тип ДС</span><span class="v">' + (f.supp_type || '—') + '</span></div>';
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
leftHtml = '<span style="color:var(--destructive);">Ошибка</span>';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
leftHtml = '<span style="color:var(--muted);">(ещё не загружен)</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
var html = '<div class="text-panes">' +
|
||||||
|
'<div class="text-pane"><div class="pane-title">Сырой текст</div>' + leftHtml + '</div>' +
|
||||||
|
'<div class="text-pane"><div class="pane-title">Распарсено</div>' + rightHtml + '</div>' +
|
||||||
|
'</div>';
|
||||||
|
document.getElementById('modalBody').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.closeModal = closeModal;
|
||||||
|
window.showText = showText;
|
||||||
|
|
||||||
|
// ── Промпт ───────────────────────────────────────────────────
|
||||||
|
var promptEditor = document.getElementById('promptEditor');
|
||||||
|
// ── Промпты (версионные) ─────────────────────────────────────
|
||||||
|
var promptRole = 'diff'; // текущая выбранная роль
|
||||||
|
var promptEditor = document.getElementById('promptEditor');
|
||||||
|
var promptStatus = document.getElementById('promptStatus');
|
||||||
|
var promptHistory = document.getElementById('promptHistory');
|
||||||
|
var promptModalOverlay = document.getElementById('promptModalOverlay');
|
||||||
|
var promptModalTitle = document.getElementById('promptModalTitle');
|
||||||
|
var promptName = document.getElementById('promptName');
|
||||||
|
var promptNotes = document.getElementById('promptNotes');
|
||||||
|
var activePromptId = {extract: '', diff: ''};
|
||||||
|
|
||||||
|
function openPromptModal(role) {
|
||||||
|
promptRole = role;
|
||||||
|
promptModalTitle.textContent = 'Промпт: ' + (role === 'extract' ? 'Извлечение' : 'Сравнение');
|
||||||
|
document.getElementById('promptTabExtract').style.background = role === 'extract' ? 'var(--brand-primary)' : '';
|
||||||
|
document.getElementById('promptTabExtract').style.color = role === 'extract' ? '#fff' : '';
|
||||||
|
document.getElementById('promptTabDiff').style.background = role === 'diff' ? 'var(--brand-primary)' : '';
|
||||||
|
document.getElementById('promptTabDiff').style.color = role === 'diff' ? '#fff' : '';
|
||||||
|
promptStatus.textContent = '';
|
||||||
|
promptName.value = '';
|
||||||
|
promptNotes.value = '';
|
||||||
|
loadPrompt(role);
|
||||||
|
promptModalOverlay.classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePromptModal(e) {
|
||||||
|
if (e && e.target !== promptModalOverlay) return;
|
||||||
|
promptModalOverlay.classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPrompt(role) {
|
||||||
|
fetch('/prompt.cfm?action=get_active&role=' + role)
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(d) {
|
||||||
|
if (d.OK) {
|
||||||
|
promptEditor.value = d.BODY || '';
|
||||||
|
activePromptId[role] = d.ID || '';
|
||||||
|
loadHistory(role);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(e) { console.error('loadPrompt:', e); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadHistory(role) {
|
||||||
|
fetch('/prompt.cfm?action=list&role=' + role)
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(d) {
|
||||||
|
if (!d.OK || !d.VERSIONS || !d.VERSIONS.length) {
|
||||||
|
promptHistory.innerHTML = '<div style="color:var(--muted);">нет версий</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
promptHistory.innerHTML = d.VERSIONS.map(function(v) {
|
||||||
|
var isActive = v.IS_ACTIVE ? '✓ ' : '';
|
||||||
|
var style = v.IS_ACTIVE ? 'font-weight:600;' : '';
|
||||||
|
return '<div style="display:flex;align-items:center;padding:4px 0;border-bottom:1px solid var(--brand-gray);font-size:11px;' + style + '">'
|
||||||
|
+ '<span style="flex:1;">' + isActive + escHtml(v.NAME) + ' <span style="color:var(--muted);">' + fmtDate(v.CREATED_AT) + '</span></span>'
|
||||||
|
+ (!v.IS_ACTIVE ? '<button class="arrow-btn" onclick="activatePrompt(\'' + v.ID + '\')" title="Активировать" style="font-size:14px;">←</button>' : '')
|
||||||
|
+ (!v.IS_ACTIVE ? '<button class="arrow-btn" onclick="deletePrompt(\'' + v.ID + '\')" title="Удалить" style="font-size:14px;">✕</button>' : '')
|
||||||
|
+ '</div>';
|
||||||
|
}).join('');
|
||||||
|
})
|
||||||
|
.catch(function(e) { console.error('loadHistory:', e); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||||
|
function fmtDate(d) { if (!d) return ''; return d.replace('T', ' ').substring(0, 16); }
|
||||||
|
|
||||||
|
function activatePrompt(id) {
|
||||||
|
fetch('/prompt.cfm?action=activate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({id: id})
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(d) {
|
||||||
|
if (d.OK) { loadPrompt(promptRole); promptStatus.textContent = '✓ активирован'; }
|
||||||
|
else { promptStatus.textContent = '✕ ' + (d.ERROR || 'ошибка'); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletePrompt(id) {
|
||||||
|
if (!confirm('Удалить эту версию промпта?')) return;
|
||||||
|
fetch('/prompt.cfm?action=delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({id: id})
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(d) {
|
||||||
|
if (d.OK) { loadHistory(promptRole); promptStatus.textContent = '✓ удалено'; }
|
||||||
|
else { promptStatus.textContent = '✕ ' + (d.ERROR || 'ошибка'); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('promptSave').addEventListener('click', function() {
|
||||||
|
var body = promptEditor.value.trim();
|
||||||
|
if (!body) { promptStatus.textContent = '✕ тело промпта пустое'; return; }
|
||||||
|
var name = promptName.value.trim() || ('v' + new Date().toISOString().replace(/[:.]/g,'-').substring(0,16));
|
||||||
|
fetch('/prompt.cfm?action=save', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({
|
||||||
|
role: promptRole, name: name, body: body,
|
||||||
|
parent_id: activePromptId[promptRole] || '',
|
||||||
|
notes: promptNotes.value.trim(), is_active: true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(d) {
|
||||||
|
if (d.OK) {
|
||||||
|
promptStatus.textContent = '✓ сохранено (новая версия)';
|
||||||
|
promptName.value = ''; promptNotes.value = '';
|
||||||
|
loadPrompt(promptRole);
|
||||||
|
} else {
|
||||||
|
promptStatus.textContent = '✕ ' + (d.ERROR || 'ошибка');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('promptTabExtract').addEventListener('click', function() { openPromptModal('extract'); });
|
||||||
|
document.getElementById('promptTabDiff').addEventListener('click', function() { openPromptModal('diff'); });
|
||||||
|
document.getElementById('promptBtnExtract').addEventListener('click', function() { openPromptModal('extract'); });
|
||||||
|
document.getElementById('promptBtnDiff').addEventListener('click', function() { openPromptModal('diff'); });
|
||||||
|
</script>
|
||||||
@@ -10,6 +10,13 @@ server {
|
|||||||
proxy_read_timeout 120s;
|
proxy_read_timeout 120s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location = /app.js {
|
||||||
|
alias /home/naeel/contracts/app.js;
|
||||||
|
add_header Content-Type "application/javascript; charset=utf-8";
|
||||||
|
add_header Access-Control-Allow-Origin "*";
|
||||||
|
expires 0;
|
||||||
|
}
|
||||||
|
|
||||||
location /lucee/ {
|
location /lucee/ {
|
||||||
rewrite ^/lucee/(.*) /$1 break;
|
rewrite ^/lucee/(.*) /$1 break;
|
||||||
proxy_pass https://contractor.luceek8s.dev.nubes.ru;
|
proxy_pass https://contractor.luceek8s.dev.nubes.ru;
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<img src="/nubes-logo.svg" alt="Nubes">
|
<img src="/nubes-logo.svg" alt="Nubes">
|
||||||
<span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.0.158 — Lucee</span></span>
|
<span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.0.159 — Lucee</span></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
@@ -146,7 +146,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Модалка промптов ── -->
|
|
||||||
<div class="modal-overlay" id="promptModalOverlay" onclick="closePromptModal(event)">
|
<div class="modal-overlay" id="promptModalOverlay" onclick="closePromptModal(event)">
|
||||||
<div class="modal" style="max-width:680px;" onclick="event.stopPropagation()">
|
<div class="modal" style="max-width:680px;" onclick="event.stopPropagation()">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
@@ -172,7 +171,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Модалка "О сервисе" ── -->
|
|
||||||
<div class="modal-overlay" id="aboutModalOverlay" onclick="closeAbout(event)">
|
<div class="modal-overlay" id="aboutModalOverlay" onclick="closeAbout(event)">
|
||||||
<div class="modal" style="max-width:640px;" onclick="event.stopPropagation()">
|
<div class="modal" style="max-width:640px;" onclick="event.stopPropagation()">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
@@ -181,668 +179,13 @@
|
|||||||
<button class="remove-btn" onclick="closeAbout()" style="margin-left:auto;font-size:18px;">×</button>
|
<button class="remove-btn" onclick="closeAbout()" style="margin-left:auto;font-size:18px;">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body" style="padding:16px;font-size:13px;line-height:1.6;max-height:65vh;overflow-y:auto;" id="aboutBody">
|
<div class="modal-body" style="padding:16px;font-size:13px;line-height:1.6;max-height:65vh;overflow-y:auto;" id="aboutBody">
|
||||||
<p><b>Что делает:</b> автоматическое сравнение договоров и дополнительных документов облачного провайдера (colocation, ЦОД). Юрист загружает файлы, система извлекает спецификации услуг и при помощи LLM находит изменения: что добавилось, изменилось, удалилось.</p>
|
<p><b>Что делает:</b> автоматическое сравнение договоров и дополнительных документов облачного провайдера.</p>
|
||||||
|
<p><b>Пайплайн:</b> Загрузка → Парсинг → Порядок (↕) → LLM-сравнение → Результаты (Event Sourcing)</p>
|
||||||
<p><b>Пайплайн:</b> Загрузка → Парсинг → Порядок (↕) → LLM-сравнение → Результаты</p>
|
<p><b>Стек:</b> Lucee (домен) + Python 3 (VM, вся логика) + PostgreSQL 15 + gpt-oss-120b</p>
|
||||||
<ol>
|
|
||||||
<li><b>Загрузка</b> — поддерживаются .docx, .doc, .pdf. <i>(ZIP временно отключён)</i></li>
|
|
||||||
<li><b>Парсинг</b> — Apache POI (Word) и PDFBox (PDF). Результат: структурированный JSON и текст.</li>
|
|
||||||
<li><b>Порядок</b> — стрелки ↕ меняют очерёдность. Первый в списке — базовый договор.</li>
|
|
||||||
<li><b>LLM-сравнение</b> — каждый следующий документ сравнивается с накопленной спецификацией. Операции: ADD (новая услуга), UPDATE (изменение), DELETE (исключена), UNRESOLVED (не удалось сопоставить).</li>
|
|
||||||
<li><b>Результаты</b> — накапливаются (Event Sourcing). Итоговая спецификация = сумма всех операций.</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<p><b>Event Sourcing:</b> изменения не перезаписывают спецификацию. Каждая операция — событие в spec_events, текущее состояние — spec_current. Это даёт аудит, откат, provenance (документ-источник, версия промпта, полный ответ LLM).</p>
|
|
||||||
|
|
||||||
<p><b>Промпты:</b> хранятся в БД, версионируются. Два вида: для первого документа (извлечение) и для сравнения. Редактор с историей — сохранил → сразу используется. Откат в один клик.</p>
|
|
||||||
|
|
||||||
<p><b>Стек:</b> Lucee 6.0 (CFML, k8s) + PostgreSQL 15 (JSONB, UUID) + Apache POI + Python 3 (LLM-прокси, SSE) + gpt-oss-120b</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script src="https://contracts.kube5s.ru/app.js"></script>
|
||||||
lucide.createIcons();
|
|
||||||
|
|
||||||
var UPLOAD_URL = 'https://contracts.kube5s.ru/upload';
|
|
||||||
var CONVERT_URL = 'https://contracts.kube5s.ru/convert-doc';
|
|
||||||
var UNZIP_URL = 'https://contracts.kube5s.ru/unzip-upload';
|
|
||||||
var SITE_URL = ''; // same origin for api calls
|
|
||||||
|
|
||||||
var fileInput = document.getElementById('fileInput');
|
|
||||||
var fileTable = document.getElementById('fileTable');
|
|
||||||
var fileQueue = [];
|
|
||||||
var contractId = null;
|
|
||||||
|
|
||||||
function formatSize(bytes) {
|
|
||||||
if (!bytes || bytes === 0) return '—';
|
|
||||||
if (bytes < 1024) return bytes + ' B';
|
|
||||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
|
||||||
return (bytes / 1048576).toFixed(1) + ' MB';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(ts) {
|
|
||||||
if (!ts) return '—';
|
|
||||||
var d = new Date(ts);
|
|
||||||
return d.toLocaleDateString('ru-RU') + ' ' + d.toLocaleTimeString('ru-RU', {hour:'2-digit',minute:'2-digit'});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTable() {
|
|
||||||
if (fileQueue.length === 0) {
|
|
||||||
fileTable.innerHTML = '<tr class="empty-row"><td colspan="7">Нет файлов — выберите .docx / .pdf</td></tr>';
|
|
||||||
} else {
|
|
||||||
fileTable.innerHTML = fileQueue.map(function(f, i) {
|
|
||||||
var isFirst = (i === 0);
|
|
||||||
var isLast = (i === fileQueue.length - 1);
|
|
||||||
var arrows = '<button class="arrow-btn" onclick="moveUp(' + i + ')" ' + (isFirst ? 'disabled' : '') + ' title="Вверх">▲</button>' +
|
|
||||||
'<button class="arrow-btn" onclick="moveDown(' + i + ')" ' + (isLast ? 'disabled' : '') + ' title="Вниз">▼</button>';
|
|
||||||
return '<tr id="row_' + i + '" class="' + (isFirst ? 'first-row' : '') + '">' +
|
|
||||||
'<td class="name-cell">' + f.name + '</td>' +
|
|
||||||
'<td style="font-size:12px;color:var(--muted);">' + formatDate(f.lastModified) + '</td>' +
|
|
||||||
'<td class="num-cell" style="font-size:12px;color:var(--muted);">' + formatSize(f.size) + '</td>' +
|
|
||||||
'<td class="status-cell">' + (f.status || '') + '</td>' +
|
|
||||||
'<td style="text-align:center;">' + arrows + '</td>' +
|
|
||||||
'<td><button class="info-btn' + (f.doc_id ? ' visible' : '') + '" onclick="showText(' + i + ')" title="Текст / Распарсено"><i data-lucide="file-text" style="width:16px;height:16px;"></i></button></td>' +
|
|
||||||
'<td><button class="remove-btn" onclick="removeFile(' + i + ')" title="Удалить"><i data-lucide="trash-2" style="width:16px;height:16px;"></i></button></td>' +
|
|
||||||
'</tr>';
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
lucide.createIcons();
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeFile(i) {
|
|
||||||
fileQueue.splice(i, 1);
|
|
||||||
if (fileQueue.length === 0) contractId = null;
|
|
||||||
renderTable();
|
|
||||||
}
|
|
||||||
window.removeFile = removeFile;
|
|
||||||
|
|
||||||
function moveUp(i) {
|
|
||||||
if (i <= 0) return;
|
|
||||||
var tmp = fileQueue[i]; fileQueue[i] = fileQueue[i-1]; fileQueue[i-1] = tmp;
|
|
||||||
renderTable();
|
|
||||||
}
|
|
||||||
function moveDown(i) {
|
|
||||||
if (i >= fileQueue.length - 1) return;
|
|
||||||
var tmp = fileQueue[i]; fileQueue[i] = fileQueue[i+1]; fileQueue[i+1] = tmp;
|
|
||||||
renderTable();
|
|
||||||
}
|
|
||||||
window.moveUp = moveUp;
|
|
||||||
window.moveDown = moveDown;
|
|
||||||
|
|
||||||
// ── О сервисе ────────────────────────────────────────────────
|
|
||||||
function openAbout() {
|
|
||||||
document.getElementById('aboutModalOverlay').classList.add('open');
|
|
||||||
}
|
|
||||||
function closeAbout(e) {
|
|
||||||
if (e && e.target !== document.getElementById('aboutModalOverlay')) return;
|
|
||||||
document.getElementById('aboutModalOverlay').classList.remove('open');
|
|
||||||
}
|
|
||||||
window.openAbout = openAbout;
|
|
||||||
window.closeAbout = closeAbout;
|
|
||||||
|
|
||||||
// ── Автозагрузка при выборе файлов ──────────────────────────
|
|
||||||
fileInput.addEventListener('change', async function() {
|
|
||||||
var newFiles = Array.from(fileInput.files);
|
|
||||||
if (newFiles.length === 0) return;
|
|
||||||
|
|
||||||
fileInput.disabled = true;
|
|
||||||
|
|
||||||
for (var i = 0; i < newFiles.length; i++) {
|
|
||||||
var f = newFiles[i];
|
|
||||||
var isZip = f.name.toLowerCase().endsWith('.zip');
|
|
||||||
|
|
||||||
if (isZip) {
|
|
||||||
// ZIP: показать временную строку, распаковать, убрать
|
|
||||||
var zipEntry = { name: f.name, lastModified: f.lastModified, size: f.size, status: '⏳ распаковка...' };
|
|
||||||
fileQueue.push(zipEntry);
|
|
||||||
var zipIdx = fileQueue.length - 1;
|
|
||||||
renderTable();
|
|
||||||
|
|
||||||
try {
|
|
||||||
var zipResp = await new Promise(function(resolve, reject) {
|
|
||||||
var xhr = new XMLHttpRequest();
|
|
||||||
xhr.open('POST', UNZIP_URL);
|
|
||||||
xhr.responseType = 'json';
|
|
||||||
xhr.onload = function() { resolve(xhr.response); };
|
|
||||||
xhr.onerror = function() { reject(new Error('Сеть')); };
|
|
||||||
xhr.ontimeout = function() { reject(new Error('Таймаут')); };
|
|
||||||
xhr.timeout = 60000;
|
|
||||||
xhr.send(f);
|
|
||||||
});
|
|
||||||
var zipData = zipResp;
|
|
||||||
if (!zipData.ok || !zipData.files) throw new Error('unzip failed');
|
|
||||||
|
|
||||||
// Убрать строку ZIP из таблицы
|
|
||||||
fileQueue.splice(zipIdx, 1);
|
|
||||||
renderTable();
|
|
||||||
|
|
||||||
// Добавить файлы из архива, пропуская дубликаты
|
|
||||||
for (var zi = 0; zi < zipData.files.length; zi++) {
|
|
||||||
var zf = zipData.files[zi];
|
|
||||||
if (zf.error) continue; // пропускаем ошибки
|
|
||||||
|
|
||||||
// Проверить дубликат по имени
|
|
||||||
var dup = -1;
|
|
||||||
for (var dj = 0; dj < fileQueue.length; dj++) {
|
|
||||||
if (fileQueue[dj].name === zf.filename) { dup = dj; break; }
|
|
||||||
}
|
|
||||||
if (dup >= 0) continue; // уже есть — не добавляем
|
|
||||||
|
|
||||||
var zEntry = {
|
|
||||||
name: zf.filename, size: zf.size,
|
|
||||||
doc_id: zf.doc_id, uploaded: true,
|
|
||||||
status: '⏳ парсинг...'
|
|
||||||
};
|
|
||||||
fileQueue.push(zEntry);
|
|
||||||
var zIdx = fileQueue.length - 1;
|
|
||||||
if (!contractId && zf.contract_id) contractId = zf.contract_id;
|
|
||||||
renderTable();
|
|
||||||
|
|
||||||
// Авто-парсинг: VM Python парсит ВСЁ
|
|
||||||
var zpr = zf.parsed;
|
|
||||||
if (zpr && zpr.status === 'parsed') {
|
|
||||||
fileQueue[zIdx].parsed = true;
|
|
||||||
fileQueue[zIdx].status = '<span class="status-ok">✓ ' + zpr.element_count + ' эл.</span>';
|
|
||||||
} else if (zpr && zpr.status === 'error') {
|
|
||||||
fileQueue[zIdx].status = '<span class="status-err">✗ ' + (zpr.error || 'ошибка парсинга') + '</span>';
|
|
||||||
} else {
|
|
||||||
fileQueue[zIdx].status = '<span class="status-err">✗ Неизвестная ошибка</span>';
|
|
||||||
}
|
|
||||||
renderTable();
|
|
||||||
}
|
|
||||||
} catch(ze) {
|
|
||||||
fileQueue[zipIdx].status = '<span class="status-err">✗ ZIP: ' + ze.message + '</span>';
|
|
||||||
renderTable();
|
|
||||||
}
|
|
||||||
continue; // ZIP обработан, переходим к следующему файлу
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обычный файл (не ZIP)
|
|
||||||
var entry = { name: f.name, lastModified: f.lastModified, size: f.size, file: f, status: '↑ 0%' };
|
|
||||||
var existingIdx = -1;
|
|
||||||
for (var j = 0; j < fileQueue.length; j++) {
|
|
||||||
if (fileQueue[j].name === f.name) { existingIdx = j; break; }
|
|
||||||
}
|
|
||||||
var rowIdx;
|
|
||||||
if (existingIdx >= 0) {
|
|
||||||
fileQueue[existingIdx] = entry;
|
|
||||||
rowIdx = existingIdx;
|
|
||||||
} else {
|
|
||||||
fileQueue.push(entry);
|
|
||||||
rowIdx = fileQueue.length - 1;
|
|
||||||
}
|
|
||||||
renderTable();
|
|
||||||
|
|
||||||
try {
|
|
||||||
var resp = await uploadFile(f, function(pct) {
|
|
||||||
fileQueue[rowIdx].status = '↑ ' + pct + '%';
|
|
||||||
renderTable();
|
|
||||||
});
|
|
||||||
// Python API returns lowercase keys (doc_id, contract_id, parsed)
|
|
||||||
if (resp && resp.contract_id) contractId = resp.contract_id;
|
|
||||||
fileQueue[rowIdx].doc_id = resp.doc_id;
|
|
||||||
fileQueue[rowIdx].status = '<span class="status-ok">✓</span>';
|
|
||||||
fileQueue[rowIdx].uploaded = true;
|
|
||||||
|
|
||||||
// Авто-парсинг: VM Python парсит ВСЁ (PDF + DOCX + DOC)
|
|
||||||
var t0 = Date.now();
|
|
||||||
var pr = resp.parsed;
|
|
||||||
if (pr && pr.status === 'parsed') {
|
|
||||||
var elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
|
||||||
fileQueue[rowIdx].parsed = true;
|
|
||||||
fileQueue[rowIdx].parseInfo = pr;
|
|
||||||
fileQueue[rowIdx].status = '<span class="status-ok">✓ ' + pr.element_count + ' эл. (' + elapsed + 'с)</span>';
|
|
||||||
} else if (pr && pr.status === 'error') {
|
|
||||||
fileQueue[rowIdx].status = '<span class="status-err">✗ ' + (pr.error || 'ошибка парсинга') + '</span>';
|
|
||||||
fileQueue[rowIdx].parseInfo = pr;
|
|
||||||
} else {
|
|
||||||
fileQueue[rowIdx].status = '<span class="status-err">✗ Неизвестная ошибка</span>';
|
|
||||||
}
|
|
||||||
} catch(err) {
|
|
||||||
fileQueue[rowIdx].status = '<span class="status-err">✗ ' + err.message + '</span>';
|
|
||||||
}
|
|
||||||
renderTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
await refreshSupps();
|
|
||||||
|
|
||||||
fileInput.value = '';
|
|
||||||
fileInput.disabled = false;
|
|
||||||
// Показать кнопки LLM после авто-парсинга
|
|
||||||
var llmBtn = document.getElementById('llmBtn');
|
|
||||||
llmBtn.style.display = 'flex';
|
|
||||||
llmBtn.disabled = false;
|
|
||||||
lucide.createIcons();
|
|
||||||
});
|
|
||||||
|
|
||||||
function uploadFile(file, onProgress) {
|
|
||||||
return new Promise(function(resolve, reject) {
|
|
||||||
// .doc → конвертация в docx
|
|
||||||
var isDoc = file.name.toLowerCase().endsWith('.doc') && !file.name.toLowerCase().endsWith('.docx');
|
|
||||||
var uploadFile = file;
|
|
||||||
var uploadName = file.name;
|
|
||||||
|
|
||||||
function doUpload() {
|
|
||||||
var xhr = new XMLHttpRequest();
|
|
||||||
var fd = new FormData();
|
|
||||||
fd.append('files', uploadFile, uploadName);
|
|
||||||
if (contractId) fd.append('contract_id', contractId);
|
|
||||||
xhr.open('POST', UPLOAD_URL);
|
|
||||||
xhr.upload.onprogress = function(e) {
|
|
||||||
if (e.lengthComputable && onProgress) onProgress(Math.round(e.loaded / e.total * 100));
|
|
||||||
};
|
|
||||||
xhr.onload = function() {
|
|
||||||
try {
|
|
||||||
var r = JSON.parse(xhr.responseText);
|
|
||||||
if (r.ok) resolve(r);
|
|
||||||
else reject(new Error(r.error || 'Неизвестная ошибка'));
|
|
||||||
} catch(e) { reject(new Error('Некорректный ответ')); }
|
|
||||||
};
|
|
||||||
xhr.onerror = function() { reject(new Error('Сеть')); };
|
|
||||||
xhr.ontimeout = function() { reject(new Error('Таймаут')); };
|
|
||||||
xhr.timeout = 180000;
|
|
||||||
xhr.send(fd);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDoc) {
|
|
||||||
var xhr = new XMLHttpRequest();
|
|
||||||
xhr.open('POST', CONVERT_URL);
|
|
||||||
xhr.responseType = 'blob';
|
|
||||||
xhr.onload = function() {
|
|
||||||
if (xhr.status === 200 && xhr.response.size > 100) {
|
|
||||||
uploadFile = xhr.response;
|
|
||||||
uploadName = file.name.replace(/\.doc$/i, '.docx');
|
|
||||||
doUpload();
|
|
||||||
} else {
|
|
||||||
reject(new Error('Конвертация .doc'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
xhr.onerror = function() { reject(new Error('Конвертер')); };
|
|
||||||
xhr.send(file);
|
|
||||||
} else {
|
|
||||||
doUpload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── LLM: Сравнить (Event Sourcing) ────────────────────────
|
|
||||||
document.getElementById('llmBtn').addEventListener('click', function() {
|
|
||||||
if (!contractId) return;
|
|
||||||
var btn = this;
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.innerHTML = '<span class="spinner"></span> Сравнение...';
|
|
||||||
|
|
||||||
var diffCard = document.getElementById('diffCard');
|
|
||||||
var diffBody = document.getElementById('diffBody');
|
|
||||||
var diffStatus = document.getElementById('diffStatus');
|
|
||||||
diffCard.style.display = 'block';
|
|
||||||
diffBody.innerHTML = '';
|
|
||||||
diffStatus.textContent = '⏳ 0.0с';
|
|
||||||
|
|
||||||
var llmStart = Date.now();
|
|
||||||
var timerInterval = setInterval(function() {
|
|
||||||
diffStatus.textContent = '⏳ ' + ((Date.now() - llmStart) / 1000).toFixed(1) + 'с';
|
|
||||||
}, 200);
|
|
||||||
|
|
||||||
var order = fileQueue.map(function(f) { return f.supp_id; }).filter(Boolean).join(',');
|
|
||||||
var es = new EventSource('https://contracts.kube5s.ru/process-v2?contract_id=' + contractId + (order ? '&order=' + encodeURIComponent(order) : ''));
|
|
||||||
var sections = {}; // supplement_id → DOM element
|
|
||||||
|
|
||||||
es.onmessage = function(e) {
|
|
||||||
var d = JSON.parse(e.data);
|
|
||||||
|
|
||||||
if (d.type === 'extract_start') {
|
|
||||||
var sec = document.createElement('div');
|
|
||||||
sec.style.cssText = 'margin-top:8px;border:1px solid var(--brand-gray);border-radius:6px;overflow:hidden;';
|
|
||||||
sec.innerHTML = '<div class="diff-section-header" style="padding:8px 12px;background:var(--brand-grey-light);cursor:pointer;font-size:13px;font-weight:600;">⏳ ' + d.filename + '</div><div class="diff-section-body" style="display:none;padding:8px 12px;"></div>';
|
|
||||||
sec.querySelector('.diff-section-header').addEventListener('click', function() {
|
|
||||||
var body = sec.querySelector('.diff-section-body');
|
|
||||||
body.style.display = body.style.display === 'none' ? 'block' : 'none';
|
|
||||||
});
|
|
||||||
diffBody.appendChild(sec);
|
|
||||||
sections[d.supplement_id] = sec;
|
|
||||||
}
|
|
||||||
else if (d.type === 'llm_done') {
|
|
||||||
var sec = sections[d.supplement_id];
|
|
||||||
if (sec) {
|
|
||||||
sec.querySelector('.diff-section-header').innerHTML = '✓ ' + d.filename + ' — ' + d.ops_count + ' оп., ' + d.mode + ' (' + d.time_s + 'с)';
|
|
||||||
sec.querySelector('.diff-section-header').style.color = 'var(--green)';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (d.type === 'applied') {
|
|
||||||
var sec = sections[d.supplement_id];
|
|
||||||
if (sec && d.ops && d.ops.length > 0) {
|
|
||||||
var s = d.summary || {};
|
|
||||||
var body = sec.querySelector('.diff-section-body');
|
|
||||||
var html = '<div style="font-size:12px;margin-bottom:6px;">+' + (s.added||0) + ' ~' + (s.updated||0) + ' -' + (s.deleted||0) + ' ?' + (s.unresolved||0) + '</div>';
|
|
||||||
html += '<div class="table-wrap"><table style="font-size:11px;"><thead><tr><th>Действие</th><th>Услуга</th><th>Цена</th><th>Кол-во</th><th>Сумма</th><th>Дата</th></tr></thead><tbody>';
|
|
||||||
d.ops.forEach(function(op) {
|
|
||||||
var action = op.action;
|
|
||||||
var cls = action === 'ADD' ? 'diff-added' : action === 'DELETE' ? 'diff-deleted' : action === 'UPDATE' ? 'diff-changed' : '';
|
|
||||||
var nr = op.new_row || {};
|
|
||||||
var name = nr.name || '';
|
|
||||||
var price = nr.price != null ? nr.price : '';
|
|
||||||
var qty = nr.qty != null ? nr.qty : '';
|
|
||||||
var sum = nr.sum != null ? nr.sum : '';
|
|
||||||
var ds = nr.date_start || '';
|
|
||||||
html += '<tr class="' + cls + '"><td>' + action + '</td><td>' + name + '</td><td class="num-cell">' + price + '</td><td class="num-cell">' + qty + '</td><td class="num-cell">' + sum + '</td><td>' + ds + '</td></tr>';
|
|
||||||
});
|
|
||||||
html += '</tbody></table></div>';
|
|
||||||
body.innerHTML = html;
|
|
||||||
body.style.display = 'block';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (d.type === 'extract_error' || d.type === 'apply_error') {
|
|
||||||
diffBody.innerHTML += '<div style="font-size:12px;color:var(--destructive);">✗ ' + d.filename + ': ' + d.error + '</div>';
|
|
||||||
}
|
|
||||||
else if (d.type === 'done') {
|
|
||||||
clearInterval(timerInterval);
|
|
||||||
es.close();
|
|
||||||
diffStatus.textContent = '✓ ' + d.total_time_s + 'с';
|
|
||||||
btn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px;"></i> ✓ Готово';
|
|
||||||
if (d.total_unresolved > 0) {
|
|
||||||
diffBody.innerHTML += '<div style="margin-top:8px;color:#eab308;">⚠ ' + d.total_unresolved + ' неразрешённых строк — <a href="/resolve.cfm?contract_id=' + contractId + '">разобрать</a></div>';
|
|
||||||
}
|
|
||||||
document.getElementById('chatCard').style.display = 'block';
|
|
||||||
lucide.createIcons();
|
|
||||||
}
|
|
||||||
else if (d.type === 'error') {
|
|
||||||
clearInterval(timerInterval);
|
|
||||||
es.close();
|
|
||||||
diffBody.innerHTML += '<div style="color:var(--destructive);margin-top:8px;">✗ ' + d.message + '</div>';
|
|
||||||
btn.innerHTML = '<i data-lucide="scale" style="width:16px;height:16px;"></i> Сравнить';
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
es.onerror = function() {
|
|
||||||
clearInterval(timerInterval);
|
|
||||||
es.close();
|
|
||||||
if (diffBody.innerHTML === '') {
|
|
||||||
diffBody.innerHTML = '<div style="color:var(--destructive);">✗ Ошибка соединения</div>';
|
|
||||||
}
|
|
||||||
btn.innerHTML = '<i data-lucide="scale" style="width:16px;height:16px;"></i> Сравнить';
|
|
||||||
btn.disabled = false;
|
|
||||||
lucide.createIcons();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Обновить supplement_id/type для radio ──────────────────
|
|
||||||
async function refreshSupps() {
|
|
||||||
if (!contractId) { console.log('refreshSupps: no contractId'); return; }
|
|
||||||
try {
|
|
||||||
var resp = await fetch(VM_API + '/api/supplements?contract_id=' + contractId);
|
|
||||||
var data = await resp.json();
|
|
||||||
console.log('refreshSupps: data=', data);
|
|
||||||
if (data.ok && data.supplements) {
|
|
||||||
data.supplements.forEach(function(supp) {
|
|
||||||
console.log('refreshSupps: supp=', supp);
|
|
||||||
for (var i = 0; i < fileQueue.length; i++) {
|
|
||||||
if (fileQueue[i].doc_id === supp.document_id) {
|
|
||||||
fileQueue[i].supp_id = supp.id;
|
|
||||||
fileQueue[i].supp_type = supp.type;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
renderTable();
|
|
||||||
}
|
|
||||||
} catch(e) { console.log('refreshSupps error:', e); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Чат ──────────────────────────────────────────────────────
|
|
||||||
document.getElementById('chatSend').addEventListener('click', function() {
|
|
||||||
var input = document.getElementById('chatInput');
|
|
||||||
var q = input.value.trim();
|
|
||||||
if (!q || !contractId) return;
|
|
||||||
var msgs = document.getElementById('chatMessages');
|
|
||||||
msgs.innerHTML += '<div style="color:var(--brand-primary);margin-top:4px;"><strong>Вы:</strong> ' + q + '</div>';
|
|
||||||
input.value = '';
|
|
||||||
input.disabled = true;
|
|
||||||
document.getElementById('chatSend').disabled = true;
|
|
||||||
msgs.innerHTML += '<div style="color:var(--muted);margin-top:2px;">⏳ Думаю...</div>';
|
|
||||||
|
|
||||||
var fd = new FormData();
|
|
||||||
fd.append('question', q);
|
|
||||||
fetch('/chat.cfm?contract_id=' + contractId, { method: 'POST', body: fd })
|
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(d) {
|
|
||||||
msgs.lastChild.remove();
|
|
||||||
msgs.innerHTML += '<div style="margin-top:2px;"><strong>Ответ:</strong> ' + (d.OK ? d.ANSWER : (d.ERROR || '—')) + '</div>';
|
|
||||||
input.disabled = false;
|
|
||||||
document.getElementById('chatSend').disabled = false;
|
|
||||||
input.focus();
|
|
||||||
}).catch(function() {
|
|
||||||
msgs.lastChild.remove();
|
|
||||||
msgs.innerHTML += '<div style="color:var(--destructive);margin-top:2px;">Ошибка сети</div>';
|
|
||||||
input.disabled = false;
|
|
||||||
document.getElementById('chatSend').disabled = false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
document.getElementById('chatInput').addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === 'Enter') document.getElementById('chatSend').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
function closeModal(e) {
|
|
||||||
if (e && e.target !== document.getElementById('modalOverlay')) return;
|
|
||||||
document.querySelector('.modal').classList.remove('wide');
|
|
||||||
document.getElementById('modalOverlay').classList.remove('open');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function showText(i) {
|
|
||||||
var f = fileQueue[i];
|
|
||||||
var docId = f.doc_id;
|
|
||||||
document.getElementById('modalTitle').textContent = f.name;
|
|
||||||
document.getElementById('modalBody').innerHTML = '<span style="color:var(--muted);">Загрузка...</span>';
|
|
||||||
var modal = document.querySelector('.modal');
|
|
||||||
modal.classList.add('wide');
|
|
||||||
document.getElementById('modalOverlay').classList.add('open');
|
|
||||||
|
|
||||||
var leftHtml = '<span style="color:var(--muted);">Загрузка...</span>';
|
|
||||||
var rightHtml = '';
|
|
||||||
|
|
||||||
if (docId) {
|
|
||||||
try {
|
|
||||||
var qResp = await fetch(VM_API + '/api/documents/' + docId);
|
|
||||||
var qData = await qResp.json();
|
|
||||||
var elements = [];
|
|
||||||
if (qData.ok && qData.elements_json) {
|
|
||||||
var ej = qData.elements_json;
|
|
||||||
if (typeof ej === 'object' && ej.Value) ej = ej.Value;
|
|
||||||
if (typeof ej === 'string') elements = JSON.parse(ej);
|
|
||||||
else elements = ej;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Общий textify для обеих панелей
|
|
||||||
var textLines = [];
|
|
||||||
elements.forEach(function(el) {
|
|
||||||
var etype = el.type || el.TYPE || '?';
|
|
||||||
if (etype === 'paragraph') {
|
|
||||||
textLines.push(el.text || el.TEXT || '');
|
|
||||||
} else if (etype === 'table') {
|
|
||||||
var rows = el.rows || el.ROWS || [];
|
|
||||||
if (rows.length > 0) {
|
|
||||||
var ncols = (rows[0]||[]).length;
|
|
||||||
textLines.push('--- Таблица ' + rows.length + '×' + ncols + ' ---');
|
|
||||||
rows.forEach(function(row) {
|
|
||||||
var cells = (row||[]).map(function(c){ return String(c||'').replace(/\n/g,' ').replace(/\|/g,'\\|'); });
|
|
||||||
textLines.push('| ' + cells.join(' | ') + ' |');
|
|
||||||
});
|
|
||||||
textLines.push('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
var textify = textLines.join('\n').replace(/</g,'<').replace(/>/g,'>');
|
|
||||||
|
|
||||||
// Левая: сырой текст (textify)
|
|
||||||
leftHtml = '<pre>' + textify + '</pre>';
|
|
||||||
|
|
||||||
// Правая: статистика + textify
|
|
||||||
rightHtml += '<div class="kv"><span class="k">Размер</span><span class="v">' + formatSize(f.size) + '</span></div>';
|
|
||||||
rightHtml += '<div class="kv"><span class="k">Изменён</span><span class="v">' + formatDate(f.lastModified) + '</span></div>';
|
|
||||||
if (f.parseInfo) {
|
|
||||||
var d = f.parseInfo;
|
|
||||||
rightHtml += '<div class="kv"><span class="k">Элементов</span><span class="v">' + (d.ELEMENT_COUNT||0) + '</span></div>';
|
|
||||||
rightHtml += '<div class="kv"><span class="k">Параграфов</span><span class="v">' + (d.PARAGRAPHS||0) + '</span></div>';
|
|
||||||
rightHtml += '<div class="kv"><span class="k">Таблиц</span><span class="v">' + (d.TABLES||0) + '</span></div>';
|
|
||||||
rightHtml += '<div class="kv"><span class="k">Строк таблиц</span><span class="v">' + (d.TABLE_ROWS||0) + '</span></div>';
|
|
||||||
if (d.PAGES) rightHtml += '<div class="kv"><span class="k">Страниц</span><span class="v">' + d.PAGES + '</span></div>';
|
|
||||||
if (d.PARSE_TIME_MS) {
|
|
||||||
rightHtml += '<div class="kv"><span class="k">Время парсинга</span><span class="v">' + (d.PARSE_TIME_MS > 1000 ? (d.PARSE_TIME_MS/1000).toFixed(1) + ' с' : d.PARSE_TIME_MS + ' мс') + '</span></div>';
|
|
||||||
}
|
|
||||||
if (d.TEXT_LENGTH) rightHtml += '<div class="kv"><span class="k">Символов</span><span class="v">' + d.TEXT_LENGTH.toLocaleString() + '</span></div>';
|
|
||||||
if (d.ERRORS && d.ERRORS.length > 0) {
|
|
||||||
rightHtml += '<div style="margin-top:8px;color:var(--destructive);font-weight:600;">Ошибки:</div>';
|
|
||||||
rightHtml += '<pre>' + d.ERRORS.join('\n') + '</pre>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rightHtml += '<div style="margin-top:10px;font-weight:600;font-size:12px;">Все элементы:</div>';
|
|
||||||
rightHtml += '<pre>' + textify + '</pre>';
|
|
||||||
if (f.supp_id) {
|
|
||||||
rightHtml += '<div class="kv"><span class="k">Тип ДС</span><span class="v">' + (f.supp_type || '—') + '</span></div>';
|
|
||||||
}
|
|
||||||
} catch(e) {
|
|
||||||
leftHtml = '<span style="color:var(--destructive);">Ошибка</span>';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
leftHtml = '<span style="color:var(--muted);">(ещё не загружен)</span>';
|
|
||||||
}
|
|
||||||
|
|
||||||
var html = '<div class="text-panes">' +
|
|
||||||
'<div class="text-pane"><div class="pane-title">Сырой текст</div>' + leftHtml + '</div>' +
|
|
||||||
'<div class="text-pane"><div class="pane-title">Распарсено</div>' + rightHtml + '</div>' +
|
|
||||||
'</div>';
|
|
||||||
document.getElementById('modalBody').innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.closeModal = closeModal;
|
|
||||||
window.showText = showText;
|
|
||||||
|
|
||||||
// ── Промпт ───────────────────────────────────────────────────
|
|
||||||
var promptEditor = document.getElementById('promptEditor');
|
|
||||||
// ── Промпты (версионные) ─────────────────────────────────────
|
|
||||||
var promptRole = 'diff'; // текущая выбранная роль
|
|
||||||
var promptEditor = document.getElementById('promptEditor');
|
|
||||||
var promptStatus = document.getElementById('promptStatus');
|
|
||||||
var promptHistory = document.getElementById('promptHistory');
|
|
||||||
var promptModalOverlay = document.getElementById('promptModalOverlay');
|
|
||||||
var promptModalTitle = document.getElementById('promptModalTitle');
|
|
||||||
var promptName = document.getElementById('promptName');
|
|
||||||
var promptNotes = document.getElementById('promptNotes');
|
|
||||||
var activePromptId = {extract: '', diff: ''};
|
|
||||||
|
|
||||||
function openPromptModal(role) {
|
|
||||||
promptRole = role;
|
|
||||||
promptModalTitle.textContent = 'Промпт: ' + (role === 'extract' ? 'Извлечение' : 'Сравнение');
|
|
||||||
document.getElementById('promptTabExtract').style.background = role === 'extract' ? 'var(--brand-primary)' : '';
|
|
||||||
document.getElementById('promptTabExtract').style.color = role === 'extract' ? '#fff' : '';
|
|
||||||
document.getElementById('promptTabDiff').style.background = role === 'diff' ? 'var(--brand-primary)' : '';
|
|
||||||
document.getElementById('promptTabDiff').style.color = role === 'diff' ? '#fff' : '';
|
|
||||||
promptStatus.textContent = '';
|
|
||||||
promptName.value = '';
|
|
||||||
promptNotes.value = '';
|
|
||||||
loadPrompt(role);
|
|
||||||
promptModalOverlay.classList.add('open');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closePromptModal(e) {
|
|
||||||
if (e && e.target !== promptModalOverlay) return;
|
|
||||||
promptModalOverlay.classList.remove('open');
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadPrompt(role) {
|
|
||||||
fetch('/prompt.cfm?action=get_active&role=' + role)
|
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(d) {
|
|
||||||
if (d.OK) {
|
|
||||||
promptEditor.value = d.BODY || '';
|
|
||||||
activePromptId[role] = d.ID || '';
|
|
||||||
loadHistory(role);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(function(e) { console.error('loadPrompt:', e); });
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadHistory(role) {
|
|
||||||
fetch('/prompt.cfm?action=list&role=' + role)
|
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(d) {
|
|
||||||
if (!d.OK || !d.VERSIONS || !d.VERSIONS.length) {
|
|
||||||
promptHistory.innerHTML = '<div style="color:var(--muted);">нет версий</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
promptHistory.innerHTML = d.VERSIONS.map(function(v) {
|
|
||||||
var isActive = v.IS_ACTIVE ? '✓ ' : '';
|
|
||||||
var style = v.IS_ACTIVE ? 'font-weight:600;' : '';
|
|
||||||
return '<div style="display:flex;align-items:center;padding:4px 0;border-bottom:1px solid var(--brand-gray);font-size:11px;' + style + '">'
|
|
||||||
+ '<span style="flex:1;">' + isActive + escHtml(v.NAME) + ' <span style="color:var(--muted);">' + fmtDate(v.CREATED_AT) + '</span></span>'
|
|
||||||
+ (!v.IS_ACTIVE ? '<button class="arrow-btn" onclick="activatePrompt(\'' + v.ID + '\')" title="Активировать" style="font-size:14px;">←</button>' : '')
|
|
||||||
+ (!v.IS_ACTIVE ? '<button class="arrow-btn" onclick="deletePrompt(\'' + v.ID + '\')" title="Удалить" style="font-size:14px;">✕</button>' : '')
|
|
||||||
+ '</div>';
|
|
||||||
}).join('');
|
|
||||||
})
|
|
||||||
.catch(function(e) { console.error('loadHistory:', e); });
|
|
||||||
}
|
|
||||||
|
|
||||||
function escHtml(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
|
||||||
function fmtDate(d) { if (!d) return ''; return d.replace('T', ' ').substring(0, 16); }
|
|
||||||
|
|
||||||
function activatePrompt(id) {
|
|
||||||
fetch('/prompt.cfm?action=activate', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({id: id})
|
|
||||||
})
|
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(d) {
|
|
||||||
if (d.OK) { loadPrompt(promptRole); promptStatus.textContent = '✓ активирован'; }
|
|
||||||
else { promptStatus.textContent = '✕ ' + (d.ERROR || 'ошибка'); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function deletePrompt(id) {
|
|
||||||
if (!confirm('Удалить эту версию промпта?')) return;
|
|
||||||
fetch('/prompt.cfm?action=delete', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({id: id})
|
|
||||||
})
|
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(d) {
|
|
||||||
if (d.OK) { loadHistory(promptRole); promptStatus.textContent = '✓ удалено'; }
|
|
||||||
else { promptStatus.textContent = '✕ ' + (d.ERROR || 'ошибка'); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('promptSave').addEventListener('click', function() {
|
|
||||||
var body = promptEditor.value.trim();
|
|
||||||
if (!body) { promptStatus.textContent = '✕ тело промпта пустое'; return; }
|
|
||||||
var name = promptName.value.trim() || ('v' + new Date().toISOString().replace(/[:.]/g,'-').substring(0,16));
|
|
||||||
fetch('/prompt.cfm?action=save', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({
|
|
||||||
role: promptRole, name: name, body: body,
|
|
||||||
parent_id: activePromptId[promptRole] || '',
|
|
||||||
notes: promptNotes.value.trim(), is_active: true
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(d) {
|
|
||||||
if (d.OK) {
|
|
||||||
promptStatus.textContent = '✓ сохранено (новая версия)';
|
|
||||||
promptName.value = ''; promptNotes.value = '';
|
|
||||||
loadPrompt(promptRole);
|
|
||||||
} else {
|
|
||||||
promptStatus.textContent = '✕ ' + (d.ERROR || 'ошибка');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('promptTabExtract').addEventListener('click', function() { openPromptModal('extract'); });
|
|
||||||
document.getElementById('promptTabDiff').addEventListener('click', function() { openPromptModal('diff'); });
|
|
||||||
document.getElementById('promptBtnExtract').addEventListener('click', function() { openPromptModal('extract'); });
|
|
||||||
document.getElementById('promptBtnDiff').addEventListener('click', function() { openPromptModal('diff'); });
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user