Files
contractor/deploy/app.js
T

845 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
var batchId = crypto.randomUUID(); // классификация: привязка всех файлов сессии
// Автоочистка старых записей без batch_id при загрузке страницы
(async function cleanup() {
try {
await fetch(VM_API + '/api/cleanup?batch=' + batchId, { method: 'POST' });
} catch(e) { /* ignore */ }
})();
// Утилиты (formatSize, formatDate, moveUp, moveDown, escHtml, fmtDate) —
// вынесены в app_utils.js для облегчения анализа и отладки.
function renderTable() {
if (fileQueue.length === 0) {
fileTable.innerHTML = '<tr class="empty-row"><td colspan="6">Нет файлов — выберите .docx / .pdf</td></tr>';
} else {
fileTable.innerHTML = fileQueue.map(function(f, i) {
return '<tr id="row_' + i + '">' +
'<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><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();
}
// moveUp/moveDown/removeFile/openAbout/closeAbout — вынесены в app_utils.js
// ── Автозагрузка при выборе файлов ──────────────────────────
fileInput.addEventListener('change', async function() {
var newFiles = Array.from(fileInput.files);
if (newFiles.length === 0) return;
fileInput.disabled = true;
// Удалить из очереди файлы, которых нет в новом выборе
var newNames = new Set(newFiles.map(function(f) { return f.name; }));
var removed = [];
fileQueue = fileQueue.filter(function(f) {
if (!newNames.has(f.name)) { removed.push(f); return false; }
return true;
});
// Удалить с сервера то, что убрали из таблицы
for (var ri = 0; ri < removed.length; ri++) {
var rf = removed[ri];
if (rf.doc_id) {
fetch(VM_API + '/api/documents/' + rf.doc_id + '?contract_id=' + (contractId||''), { method: 'DELETE' }).catch(function(){});
}
}
renderTable();
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();
// Удалить с сервера мусор — документы этого батча, которых нет в таблице
if (batchId) {
var keepIds = fileQueue.map(function(f) { return f.doc_id; }).filter(Boolean);
fetch(VM_API + '/api/sync-batch', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({batch_id: batchId, keep_ids: keepIds})
}).catch(function(){});
}
fileInput.value = '';
fileInput.disabled = false;
// Показать кнопки после авто-парсинга
var llmBtn = document.getElementById('llmBtn');
llmBtn.style.display = 'flex';
llmBtn.disabled = false;
showClassifyBtn();
lucide.createIcons();
});
// ── Классификация ──────────────────────────────────────────
function showClassifyBtn() {
var btn = document.getElementById('classifyBtn');
if (!btn) {
btn = document.createElement('button');
btn.id = 'classifyBtn';
btn.className = 'btn btn-primary';
btn.style.cssText = 'width:100%;justify-content:center;margin-top:8px;background:var(--brand-primary);border-color:var(--brand-primary);';
btn.innerHTML = '<i data-lucide="tags" style="width:16px;height:16px;"></i> Классифицировать';
btn.addEventListener('click', runClassify);
document.getElementById('llmBtn').parentNode.insertBefore(btn, document.getElementById('llmBtn'));
}
btn.style.display = 'flex';
btn.disabled = false;
}
async function runClassify() {
var btn = document.getElementById('classifyBtn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Классификация... 0с';
var start = Date.now();
var timer = setInterval(function() {
btn.innerHTML = '<span class="spinner"></span> Классификация... ' + Math.round((Date.now() - start) / 1000) + 'с';
}, 1000);
// Poll progress каждые 2с
var pollTimer = setInterval(async function() {
try {
var r = await fetch(VM_API + '/api/batch-progress?batch=' + batchId);
var d = await r.json();
if (d.ok && d.counts) {
var done = (d.counts.classified || 0) + (d.counts.failed || 0);
var total = d.total || 0;
btn.innerHTML = '<span class="spinner"></span> Классификация... ' + done + '/' + total + ' (' + Math.round((Date.now() - start) / 1000) + 'с)';
if (done >= total && total > 0) {
clearInterval(pollTimer);
}
}
} catch(e) {}
}, 2000);
try {
var resp = await fetch(VM_API + '/api/classify-batch', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({batch_id: batchId})
});
var data = await resp.json();
clearInterval(timer);
clearInterval(pollTimer);
if (data.ok) {
btn.innerHTML = '✓ ' + data.done + '/' + data.total + ' классифицировано (' + Math.round((Date.now() - start) / 1000) + 'с)';
loadGroups();
} else {
btn.innerHTML = '✗ ' + (data.error || 'ошибка');
btn.disabled = false;
}
} catch(e) {
clearInterval(timer);
clearInterval(pollTimer);
btn.innerHTML = '✗ ' + e.message;
btn.disabled = false;
}
}
async function loadGroups() {
var resp = await fetch(VM_API + '/api/groups?batch=' + batchId);
var data = await resp.json();
if (!data.ok || !data.groups) return;
var diffBody = document.getElementById('diffBody');
var diffCard = document.getElementById('diffCard');
diffCard.style.display = 'block';
var realGroups = data.groups.filter(function(g) { return g.contract_number !== '__unresolved__'; });
var unresolvedGroup = data.groups.find(function(g) { return g.contract_number === '__unresolved__'; });
diffBody.innerHTML = '<div style="font-weight:600;margin-bottom:8px;">📋 Найдено групп: ' + realGroups.length + (unresolvedGroup ? ' + ' + unresolvedGroup.documents.length + ' не распознано' : '') + '</div>';
// Сохраняем группы для compare
window._groupsData = data.groups;
data.groups.forEach(function(g, gi) {
var isUnresolved = g.contract_number === '__unresolved__';
var card = document.createElement('div');
card.style.cssText = 'border:1px solid ' + (isUnresolved ? 'var(--destructive)' : 'var(--brand-gray)') + ';border-radius:6px;padding:10px;margin-bottom:8px;';
if (isUnresolved) {
var docsHtml = g.documents.map(function(d) {
var reason = '';
if (d.parent_number) {
reason = ' — нет базового договора №' + d.parent_number;
} else if (d.classify_status === 'failed') {
reason = ' — ошибка классификации: ' + (d.error_message || '?');
} else if (!d.doc_type || d.doc_type === 'other') {
reason = ' — тип не определён';
} else {
reason = ' — нет matching-договора';
}
return '<div style="padding:2px 0;">' +
'<span style="color:var(--muted);">[' + (d.doc_type || '?') + ']</span> ' +
d.filename +
(d.doc_date ? ' <span style="color:var(--muted);">' + d.doc_date + '</span>' : '') +
'<span style="color:var(--destructive);font-size:11px;">' + reason + '</span>' +
'</div>';
}).join('');
card.innerHTML = '<div style="font-weight:600;margin-bottom:6px;">❓ Не распознано (' + g.documents.length + ' файлов)</div>' +
'<div style="font-size:12px;">' + docsHtml + '</div>' +
'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Загрузите недостающие базовые договоры для этих номеров.</div>';
} else {
card.innerHTML = '<div style="font-weight:600;margin-bottom:6px;">📄 Договор №' + (g.contract_number || '?') + ' — ' + (g.counterparty || 'контрагент не определён') + '</div>' +
'<div style="font-size:12px;">' +
g.documents.map(function(d) {
var typeLabel = {contract: 'договор', supplement: 'допсоглашение', specification: 'спецификация'}[d.doc_type] || d.doc_type;
return '<div style="padding:2px 0;">' +
'<span style="color:var(--muted);">' + (typeLabel || '?') + '</span> ' +
d.filename +
(d.doc_date ? ' <span style="color:var(--muted);">' + d.doc_date + '</span>' : '') +
'</div>';
}).join('') + '</div>';
card.innerHTML += '<button class="btn btn-primary" style="margin-top:6px;font-size:12px;height:28px;" onclick="runCompareForGroup(' + gi + ')">▶ Сравнить эту группу</button>';
}
diffBody.appendChild(card);
});
lucide.createIcons();
}
window.runCompareForGroup = async function(gi) {
var group = window._groupsData[gi];
if (!group || group.contract_number === '__unresolved__') return;
var btn = event.target;
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Применяю...';
var ar = await fetch(VM_API + '/api/apply-groups', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({batch_id: batchId, groups: [group]})
});
var ad = await ar.json();
if (!ad.ok || !ad.contract_ids || !ad.contract_ids.length) {
btn.innerHTML = '✗ ошибка'; btn.disabled = false; return;
}
var cid = ad.contract_ids[0];
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 start = Date.now();
var timer = setInterval(function() {
diffStatus.textContent = '⏳ ' + ((Date.now() - start) / 1000).toFixed(1) + 'с';
}, 200);
var es = new EventSource(VM_API + '/process-v2?contract_id=' + cid);
var sections = {};
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) + '</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 cls = op.action === 'ADD' ? 'diff-added' : op.action === 'DELETE' ? 'diff-deleted' : 'diff-changed';
var nr = op.new_row || {};
html += '<tr class="' + cls + '"><td>' + op.action + '</td><td>' + (nr.name||'') + '</td><td class="num-cell">' + (nr.price!=null?nr.price:'') + '</td><td class="num-cell">' + (nr.qty!=null?nr.qty:'') + '</td><td class="num-cell">' + (nr.sum!=null?nr.sum:'') + '</td><td>' + (nr.date_start||'') + '</td></tr>';
});
html += '</tbody></table></div>';
body.innerHTML = html;
body.style.display = 'block';
}
}
else if (d.type === 'done') {
clearInterval(timer); es.close();
diffStatus.textContent = '✓ ' + d.total_time_s + 'с';
btn.innerHTML = '✓ Готово'; btn.disabled = false;
}
else if (d.type === 'error') {
clearInterval(timer); es.close();
diffBody.innerHTML += '<div style="color:var(--destructive);">✗ ' + d.message + '</div>';
btn.innerHTML = '✗'; btn.disabled = false;
}
};
es.onerror = function() { clearInterval(timer); es.close(); btn.innerHTML = '✗ соединение'; btn.disabled = false; };
};
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);
fd.append('batch_id', batchId);
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,'&lt;').replace(/>/g,'&gt;');
// Левая: сырой текст (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>';
if (d.error) rightHtml += '<div style="margin-top:8px;color:var(--destructive);">' + d.error + '</div>';
}
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');
// Три роли промптов:
// extract — извлечение строк из первого документа (базовый договор)
// diff — сравнение допсоглашений (ADD/UPDATE/DELETE)
// classify — классификация документа (тип, номер, контрагент, дата)
var activePromptId = {extract: '', diff: '', classify: ''};
function openPromptModal(role) {
promptRole = role;
var labels = {extract: 'Извлечение', diff: 'Сравнение', classify: 'Классификация'};
promptModalTitle.textContent = 'Промпт: ' + (labels[role] || role);
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' : '';
document.getElementById('promptTabClassify').style.background = role === 'classify' ? 'var(--brand-primary)' : '';
document.getElementById('promptTabClassify').style.color = role === 'classify' ? '#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(VM_API + '/api/prompts?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(VM_API + '/api/prompts/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 ? '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--green);margin-right:4px;" title="активная"></span> ' : '';
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>'
+ (v.notes ? ' <span style="color:var(--muted);font-style:italic;">— ' + escHtml(v.notes) + '</span>' : '')
+ '</span>'
+ (!v.is_active ? '<button class="btn" onclick="activatePrompt(\'' + v.id + '\')" style="font-size:11px;height:22px;padding:0 8px;margin-left:4px;">активировать</button>' : '')
+ (!v.is_active ? '<button class="btn" onclick="deletePrompt(\'' + v.id + '\')" style="font-size:11px;height:22px;padding:0 8px;margin-left:2px;color:var(--destructive);">удалить</button>' : '')
+ '</div>';
}).join('');
})
.catch(function(e) { console.error('loadHistory:', e); });
}
function activatePrompt(id) {
fetch(VM_API + '/api/prompts/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(VM_API + '/api/prompts/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(VM_API + '/api/prompts/save', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
role: promptRole, name: name, body: body,
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('promptTabClassify').addEventListener('click', function() { openPromptModal('classify'); });
document.getElementById('promptBtnExtract').addEventListener('click', function() { openPromptModal('extract'); });
document.getElementById('promptBtnDiff').addEventListener('click', function() { openPromptModal('diff'); });
document.getElementById('promptBtnClassify').addEventListener('click', function() { openPromptModal('classify'); });
lucide.createIcons();