v1.0.40: UI — копия Flask llm.html (автозагрузка, парсинг, LLM, чат)

This commit is contained in:
2026-06-19 07:14:47 +04:00
parent e2334d6ca7
commit 75ebfb02fe
+240 -319
View File
@@ -5,17 +5,11 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Сверка договоров</title> <title>Сверка договоров — LLM</title>
<link rel="icon" type="image/png" href="/favicon.png"> <link rel="icon" type="image/png" href="/favicon.png">
<script src="https://unpkg.com/lucide@latest"></script> <script src="https://unpkg.com/lucide@latest"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<style> <style>
:root { :root { --brand-primary: #2563eb; --brand-primary-dark: #1d4ed8; --brand-gray: #d1d5db; --brand-grey-light: #f3f4f6; --background: #ffffff; --foreground: #1a1a1a; --muted: #6b7280; --destructive: #ef4444; --green: #22c55e; }
--brand-primary: #2563eb; --brand-primary-dark: #1d4ed8;
--brand-gray: #d1d5db; --brand-grey-light: #f3f4f6;
--background: #ffffff; --foreground: #1a1a1a;
--muted: #6b7280; --destructive: #ef4444; --green: #22c55e; --yellow: #eab308;
}
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; font-size: 14px; color: var(--foreground); background: var(--brand-grey-light); min-height: 100vh; } body { font-family: system-ui, sans-serif; font-size: 14px; color: var(--foreground); background: var(--brand-grey-light); min-height: 100vh; }
.topbar { background: var(--background); border-bottom: 1px solid var(--brand-gray); padding: 0 24px; height: 56px; display: flex; align-items: center; gap: 16px; } .topbar { background: var(--background); border-bottom: 1px solid var(--brand-gray); padding: 0 24px; height: 56px; display: flex; align-items: center; gap: 16px; }
@@ -29,7 +23,6 @@
.btn-primary { background: var(--brand-primary); border-color: var(--brand-primary); color: #fff; } .btn-primary { background: var(--brand-primary); border-color: var(--brand-primary); color: #fff; }
.btn-primary:hover { background: var(--brand-primary-dark); } .btn-primary:hover { background: var(--brand-primary-dark); }
.btn:disabled { opacity: .5; cursor: not-allowed; } .btn:disabled { opacity: .5; cursor: not-allowed; }
.btn-sm { height: 28px; font-size: 12px; padding: 0 10px; }
.table-wrap { border-radius: 6px; border: 1px solid var(--brand-gray); overflow: hidden; margin-top: 12px; } .table-wrap { border-radius: 6px; border: 1px solid var(--brand-gray); overflow: hidden; margin-top: 12px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; } table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { background: var(--brand-grey-light); text-transform: uppercase; padding: 6px 10px; border-right: 1px solid var(--brand-gray); text-align: left; font-weight: 600; font-size: 11px; color: var(--muted); } th { background: var(--brand-grey-light); text-transform: uppercase; padding: 6px 10px; border-right: 1px solid var(--brand-gray); text-align: left; font-weight: 600; font-size: 11px; color: var(--muted); }
@@ -39,6 +32,7 @@
.num-cell { text-align: right; white-space: nowrap; } .num-cell { text-align: right; white-space: nowrap; }
.status-ok { color: var(--green); } .status-ok { color: var(--green); }
.status-err { color: var(--destructive); } .status-err { color: var(--destructive); }
.summary-row td { background: rgba(34,197,94,.05); font-weight: 600; }
.empty-row td { color: var(--muted); text-align: center; padding: 24px; } .empty-row td { color: var(--muted); text-align: center; padding: 24px; }
.remove-btn { cursor: pointer; color: var(--muted); background: none; border: none; padding: 2px 4px; font-size: 16px; line-height: 1; } .remove-btn { cursor: pointer; color: var(--muted); background: none; border: none; padding: 2px 4px; font-size: 16px; line-height: 1; }
.remove-btn:hover { color: var(--destructive); } .remove-btn:hover { color: var(--destructive); }
@@ -53,24 +47,24 @@
.modal-body .kv { display: flex; margin-bottom: 6px; font-size: 13px; } .modal-body .kv { display: flex; margin-bottom: 6px; font-size: 13px; }
.modal-body .kv .k { color: var(--muted); width: 110px; flex-shrink: 0; } .modal-body .kv .k { color: var(--muted); width: 110px; flex-shrink: 0; }
.modal-body .kv .v { word-break: break-all; } .modal-body .kv .v { word-break: break-all; }
.modal-body pre { background: var(--brand-grey-light); padding: 10px; border-radius: 6px; font-size: 12px; max-height: 200px; overflow-y: auto; white-space: pre-wrap; margin-top: 4px; } .diff-added { background: rgba(34,197,94,.1); }
.diff-added td { background: rgba(34,197,94,.08); } .diff-deleted { background: rgba(239,68,68,.1); }
.diff-deleted td { background: rgba(239,68,68,.08); text-decoration: line-through; color: var(--destructive); } .diff-changed { background: rgba(234,179,8,.1); }
.diff-changed td { background: rgba(234,179,8,.08); } .diff-added td { color: var(--green); }
.flex-row { display: flex; gap: 8px; align-items: center; margin-top: 8px; } .diff-deleted td { color: var(--destructive); text-decoration: line-through; }
.flex-grow { flex: 1; } .diff-field-old { color: var(--destructive); text-decoration: line-through; font-size: 11px; }
#log { font-size: 12px; color: var(--muted); max-height: 200px; overflow-y: auto; background: var(--brand-grey-light); padding: 8px; border-radius: 6px; margin-top: 8px; white-space: pre-wrap; } .diff-field-new { color: var(--green); font-weight: 600; }
@keyframes spin { to { transform: rotate(360deg); } }
.spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%; animation: spin .6s linear infinite; }
</style> </style>
</head> </head>
<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">Сверка договоров <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.0.37 — Lucee</span></span> <span class="title">Сверка договоров — LLM <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.0.40 — Lucee</span></span>
</div> </div>
<div class="content"> <div class="content">
<!-- Загрузка -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<i data-lucide="folder-open" style="width:18px;height:18px;"></i> <i data-lucide="folder-open" style="width:18px;height:18px;"></i>
@@ -78,6 +72,7 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<input type="file" id="fileInput" accept=".docx,.doc,.pdf,.zip" multiple style="margin-bottom:12px;width:100%;"> <input type="file" id="fileInput" accept=".docx,.doc,.pdf,.zip" multiple style="margin-bottom:12px;width:100%;">
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead> <thead>
@@ -86,58 +81,50 @@
<tbody id="fileTable"></tbody> <tbody id="fileTable"></tbody>
</table> </table>
</div> </div>
<button class="btn btn-primary" id="uploadBtn" style="width:100%;justify-content:center;margin-top:12px;" disabled>
<i data-lucide="upload" style="width:16px;height:16px;"></i> Загрузить <button class="btn btn-primary" id="parseBtn" style="width:100%;justify-content:center;margin-top:12px;" disabled>
<i data-lucide="play" style="width:16px;height:16px;"></i> Парсинг
</button> </button>
<div id="uploadTimer" style="text-align:center;font-size:12px;color:var(--muted);margin-top:4px;display:none;"></div>
<button class="btn btn-primary" id="llmBtn" style="width:100%;justify-content:center;margin-top:8px;display:none;">
<i data-lucide="scale" style="width:16px;height:16px;"></i> Сравнить (LLM)
</button>
<details style="margin-top:8px;font-size:12px;">
<summary style="cursor:pointer;color:var(--muted);">⚙ Промпт LLM</summary>
<textarea id="promptEditor" style="width:100%;height:200px;font-family:monospace;font-size:11px;border:1px solid var(--brand-gray);border-radius:6px;padding:8px;margin-top:4px;"></textarea>
<div style="display:flex;gap:8px;margin-top:4px;">
<button class="btn" id="promptSave" style="font-size:11px;height:28px;">Сохранить</button>
<button class="btn" id="promptReset" style="font-size:11px;height:28px;">По умолчанию</button>
<span id="promptStatus" style="font-size:11px;color:var(--muted);line-height:28px;"></span>
</div>
</details>
</div> </div>
</div> </div>
<!-- Действия -->
<div class="card" id="actionsCard" style="display:none;">
<div class="card-header">
<i data-lucide="play" style="width:18px;height:18px;"></i>
Обработка
</div>
<div class="card-body">
<div style="margin-bottom:8px;">
<textarea id="promptEditor" style="width:100%;height:120px;font-family:monospace;font-size:11px;border:1px solid var(--brand-gray);border-radius:6px;padding:8px;" placeholder="Промпт для LLM (оставьте пустым для стандартного)..."></textarea>
</div>
<div class="flex-row">
<button class="btn btn-primary" id="btnParse">Парсинг</button>
<button class="btn btn-primary" id="btnExtract">LLM-извлечение</button>
<button class="btn btn-primary" id="btnDiffer">Сравнить</button>
<button class="btn btn-primary" id="btnFull" style="background:var(--green);border-color:var(--green);">⚡ Полный цикл</button>
</div>
<div id="log"></div>
</div>
</div>
<!-- Сравнение -->
<div class="card" id="diffCard" style="display:none;"> <div class="card" id="diffCard" style="display:none;">
<div class="card-header"> <div class="card-header">
<i data-lucide="git-compare" style="width:18px;height:18px;"></i> <i data-lucide="file-diff" style="width:18px;height:18px;"></i>
Сравнение Результаты сравнения <span id="diffStatus" style="font-weight:400;font-size:12px;margin-left:8px;"></span>
</div> </div>
<div class="card-body" id="diffBody"></div> <div class="card-body" id="diffBody"></div>
</div> </div>
<!-- Чат --> <div class="card" id="chatCard" style="display:none;margin-top:16px;">
<div class="card" id="chatCard" style="display:none;">
<div class="card-header"> <div class="card-header">
<i data-lucide="message-circle" style="width:18px;height:18px;"></i> <i data-lucide="message-circle" style="width:18px;height:18px;"></i>
Чат с договором Задать вопрос по договору
</div> </div>
<div class="card-body"> <div class="card-body">
<div id="chatMessages" style="margin-bottom:8px;font-size:13px;max-height:300px;overflow-y:auto;"></div> <div id="chatMessages" style="margin-bottom:8px;font-size:13px;"></div>
<div class="flex-row"> <div style="display:flex;gap:8px;">
<input type="text" id="chatInput" placeholder="Какая общая сумма? Что изменилось? ..." class="flex-grow"> <input type="text" id="chatInput" placeholder="Какая общая сумма? Что изменилось? ..." style="flex:1;height:32px;border:1px solid var(--brand-gray);border-radius:6px;padding:0 8px;font-size:13px;">
<button class="btn btn-primary" id="chatSend">→</button> <button class="btn btn-primary" id="chatSend" style="height:32px;font-size:13px;">→</button>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Модалка -->
<div class="modal-overlay" id="modalOverlay" onclick="closeModal(event)"> <div class="modal-overlay" id="modalOverlay" onclick="closeModal(event)">
<div class="modal" onclick="event.stopPropagation()"> <div class="modal" onclick="event.stopPropagation()">
<div class="modal-header"> <div class="modal-header">
@@ -149,18 +136,17 @@
</div> </div>
</div> </div>
</div>
<script> <script>
lucide.createIcons(); lucide.createIcons();
var UPLOAD_URL = 'https://contracts.kube5s.ru/lucee/upload.cfm';
var SITE_URL = ''; // same origin for api calls
var fileInput = document.getElementById('fileInput'); var fileInput = document.getElementById('fileInput');
var uploadBtn = document.getElementById('uploadBtn'); var parseBtn = document.getElementById('parseBtn');
var fileTable = document.getElementById('fileTable'); var fileTable = document.getElementById('fileTable');
var uploadTimer = document.getElementById('uploadTimer');
var fileQueue = []; var fileQueue = [];
var contractId = ''; var contractId = null;
var uploadStartTime = 0;
function formatSize(bytes) { function formatSize(bytes) {
if (!bytes || bytes === 0) return '—'; if (!bytes || bytes === 0) return '—';
@@ -175,16 +161,10 @@ function formatDate(ts) {
return d.toLocaleDateString('ru-RU') + ' ' + d.toLocaleTimeString('ru-RU', {hour:'2-digit',minute:'2-digit'}); return d.toLocaleDateString('ru-RU') + ' ' + d.toLocaleTimeString('ru-RU', {hour:'2-digit',minute:'2-digit'});
} }
function log(msg) {
var el = document.getElementById('log');
el.textContent += msg + '\n';
el.scrollTop = el.scrollHeight;
}
function renderTable() { function renderTable() {
if (fileQueue.length === 0) { if (fileQueue.length === 0) {
fileTable.innerHTML = '<tr class="empty-row"><td colspan="6">Нет файлов — выберите .docx / .pdf / .zip</td></tr>'; fileTable.innerHTML = '<tr class="empty-row"><td colspan="6">Нет файлов — выберите .docx / .pdf / .zip</td></tr>';
uploadBtn.disabled = true; parseBtn.disabled = true;
} else { } else {
fileTable.innerHTML = fileQueue.map(function(f, i) { fileTable.innerHTML = fileQueue.map(function(f, i) {
return '<tr id="row_' + i + '">' + return '<tr id="row_' + i + '">' +
@@ -192,119 +172,226 @@ function renderTable() {
'<td style="font-size:12px;color:var(--muted);">' + formatDate(f.lastModified) + '</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="num-cell" style="font-size:12px;color:var(--muted);">' + formatSize(f.size) + '</td>' +
'<td class="status-cell">' + (f.status || '') + '</td>' + '<td class="status-cell">' + (f.status || '') + '</td>' +
'<td><button class="info-btn' + (f.status ? ' visible' : '') + '" id="info_' + i + '" onclick="showInfo(' + i + ')" title="Инфо"><i data-lucide="info" style="width:16px;height:16px;"></i></button></td>' + '<td><button class="info-btn' + (f.doc_id ? ' visible' : '') + '" id="info_' + i + '" onclick="showInfo(' + i + ')" title="Инфо"><i data-lucide="info" 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>' + '<td><button class="remove-btn" onclick="removeFile(' + i + ')" title="Удалить"><i data-lucide="trash-2" style="width:16px;height:16px;"></i></button></td>' +
'</tr>'; '</tr>';
}).join(''); }).join('');
uploadBtn.disabled = false;
} }
lucide.createIcons(); lucide.createIcons();
} }
function removeFile(i) { function removeFile(i) {
fileQueue.splice(i, 1); fileQueue.splice(i, 1);
if (fileQueue.length === 0) contractId = ''; if (fileQueue.length === 0) contractId = null;
renderTable(); renderTable();
} }
window.removeFile = removeFile;
fileInput.addEventListener('change', function() { // ── Автозагрузка при выборе файлов ──────────────────────────
var files = this.files; fileInput.addEventListener('change', async function() {
for (var i = 0; i < files.length; i++) { var newFiles = Array.from(fileInput.files);
fileQueue.push({ if (newFiles.length === 0) return;
name: files[i].name,
size: files[i].size, fileInput.disabled = true;
lastModified: files[i].lastModified, parseBtn.disabled = true;
file: files[i], parseBtn.innerHTML = '<span class="spinner"></span> Загрузка...';
status: ''
}); for (var i = 0; i < newFiles.length; i++) {
} var f = newFiles[i];
this.value = ''; var entry = { name: f.name, lastModified: f.lastModified, size: f.size, file: f, status: '↑ 0%' };
fileQueue.push(entry);
var rowIdx = fileQueue.length - 1;
renderTable();
try {
var resp = await uploadFile(f, function(pct) {
fileQueue[rowIdx].status = '↑ ' + pct + '%';
renderTable(); renderTable();
}); });
if (resp && resp.CONTRACT_ID) contractId = resp.CONTRACT_ID;
// ── Загрузка через прокси (contracts.kube5s.ru/lucee/) ── fileQueue[rowIdx].doc_id = resp.DOC_ID;
var UPLOAD_URL = 'https://contracts.kube5s.ru/lucee/upload.cfm'; fileQueue[rowIdx].status = '<span class="status-ok">✓</span>';
fileQueue[rowIdx].uploaded = true;
uploadBtn.addEventListener('click', function() { } catch(err) {
if (fileQueue.length === 0) return; fileQueue[rowIdx].status = '<span class="status-err">✗ ' + err.message + '</span>';
uploadBtn.disabled = true;
uploadStartTime = Date.now();
uploadTimer.style.display = 'block';
function uploadNext(i) {
if (i >= fileQueue.length) {
uploadTimer.style.display = 'none';
uploadBtn.disabled = false;
var hasErrors = fileQueue.some(function(f) { return f.status && f.status.indexOf('error') === 0; });
if (contractId && !hasErrors) {
document.getElementById('actionsCard').style.display = 'block';
document.getElementById('chatCard').style.display = 'block';
} }
return;
}
var f = fileQueue[i];
if (f.doc_id) { uploadNext(i + 1); return; }
f.status = '0%';
renderTable(); renderTable();
uploadTimer.textContent = 'Загрузка ' + (i+1) + '/' + fileQueue.length; }
fileInput.value = '';
fileInput.disabled = false;
parseBtn.disabled = (contractId === null);
parseBtn.innerHTML = '<i data-lucide="play" style="width:16px;height:16px;"></i> Парсинг';
lucide.createIcons();
});
function uploadFile(file, onProgress) {
return new Promise(function(resolve, reject) {
var xhr = new XMLHttpRequest(); var xhr = new XMLHttpRequest();
var fd = new FormData(); var fd = new FormData();
fd.append('files', f.file); fd.append('files', file);
if (contractId) fd.append('contract_id', contractId); if (contractId) fd.append('contract_id', contractId);
xhr.open('POST', UPLOAD_URL); xhr.open('POST', UPLOAD_URL);
xhr.timeout = 180000;
xhr.upload.onprogress = function(e) { xhr.upload.onprogress = function(e) {
if (e.lengthComputable) { if (e.lengthComputable && onProgress) onProgress(Math.round(e.loaded / e.total * 100));
f.status = Math.round(e.loaded / e.total * 100) + '%';
renderTable();
}
}; };
xhr.onload = function() { xhr.onload = function() {
try { try {
var r = JSON.parse(xhr.responseText); var r = JSON.parse(xhr.responseText);
if (r.OK) { if (r.OK) resolve(r);
contractId = r.CONTRACT_ID; else reject(new Error(r.ERROR || 'Неизвестная ошибка'));
f.doc_id = r.DOC_ID; } catch(e) { reject(new Error('Некорректный ответ')); }
f.status = 'uploaded';
var elapsed = ((Date.now() - uploadStartTime) / 1000).toFixed(1);
log('✅ ' + f.name + ' (' + formatSize(f.size) + ') — ' + elapsed + 'с');
} else {
f.status = 'error: ' + (r.ERROR || 'неизв.');
log('❌ ' + f.name + ': ' + (r.ERROR || ''));
}
} catch(e) { f.status = 'error: ' + e.message; log('❌ ' + f.name + ': ' + e.message); }
renderTable();
uploadNext(i + 1);
};
xhr.onerror = function() {
f.status = 'error: сеть';
log('❌ ' + f.name + ': сеть');
renderTable();
uploadNext(i + 1);
};
xhr.ontimeout = function() {
f.status = 'error: таймаут';
log('❌ ' + f.name + ': таймаут');
renderTable();
uploadNext(i + 1);
}; };
xhr.onerror = function() { reject(new Error('Сеть')); };
xhr.ontimeout = function() { reject(new Error('Таймаут')); };
xhr.timeout = 180000;
xhr.send(fd); xhr.send(fd);
});
} }
uploadNext(0); // ── Парсинг ──────────────────────────────────────────────────
parseBtn.addEventListener('click', async function() {
if (!contractId) return;
parseBtn.disabled = true;
parseBtn.innerHTML = '<span class="spinner"></span> Парсинг...';
for (var i = 0; i < fileQueue.length; i++) {
var f = fileQueue[i];
if (!f.doc_id || f.parsed) continue;
f.status = '⏳';
renderTable();
try {
var resp = await fetch('/parser.cfm?doc_id=' + f.doc_id);
var d = await resp.json();
if (d.OK && d.STATUS === 'parsed') {
f.parsed = true;
f.status = '<span class="status-ok">✓ ' + d.ELEMENT_COUNT + ' эл.</span>';
f.element_count = d.ELEMENT_COUNT;
} else {
f.status = '<span class="status-err">✗ ' + (d.ERROR_COUNT > 0 ? 'ошибка' : '') + '</span>';
}
} catch(e) {
f.status = '<span class="status-err">✗ сеть</span>';
}
renderTable();
}
parseBtn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px;"></i> ✓ Готово';
var llmBtn = document.getElementById('llmBtn');
llmBtn.style.display = 'flex';
llmBtn.disabled = false;
lucide.createIcons();
}); });
// ── Инфо ────────────────────────────────────────────────── // ── LLM: Сравнить ────────────────────────────────────────────
document.getElementById('llmBtn').addEventListener('click', async function() {
if (!contractId) return;
var btn = this;
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> LLM-анализ...';
var diffCard = document.getElementById('diffCard');
var diffBody = document.getElementById('diffBody');
diffCard.style.display = 'block';
diffBody.innerHTML = '⏳ LLM-извлечение...';
// 1. Extract for each supplement
try {
var resp = await fetch('/api.cfm?action=query&sql=' + encodeURIComponent("SELECT id FROM supplements WHERE contract_id='" + contractId + "' ORDER BY created_at"));
var data = await resp.json();
if (data.OK && data.ROWS) {
for (var i = 0; i < data.ROWS.length; i++) {
try {
var er = await fetch('/extractor.cfm?supplement_id=' + data.ROWS[i].id);
var ed = await er.json();
if (ed.OK) diffBody.innerHTML += '<div style="font-size:12px;color:var(--green);">✓ ' + ed.ROWS_SAVED + ' строк</div>';
else diffBody.innerHTML += '<div style="font-size:12px;color:var(--destructive);">✗ ' + (ed.ERROR||'') + '</div>';
} catch(e) {}
}
}
} catch(e) {}
// 2. Differ
diffBody.innerHTML += '<div style="margin-top:8px;font-weight:600;">📊 Сравнение:</div>';
try {
var resp = await fetch('/differ.cfm?contract_id=' + contractId);
var data = await resp.json();
if (data.OK) {
var s = data.SUMMARY || {};
document.getElementById('diffStatus').textContent = 'изм:' + (s.CHANGED||0) + ' +' + (s.ADDED||0) + ' -' + (s.DELETED||0);
var changes = data.CHANGES || [];
if (changes.length === 0) {
diffBody.innerHTML += '<div style="color:var(--muted);">Нет изменений</div>';
} else {
var tbl = '<div class="table-wrap" style="margin-top:4px;"><table><thead><tr><th>№</th><th>Услуга</th><th>Цена</th><th>Объём</th><th>Сумма</th><th>Начало</th></tr></thead><tbody>';
changes.forEach(function(c) {
var cls = c.CHANGE_TYPE === 'added' ? 'diff-added' : c.CHANGE_TYPE === 'deleted' ? 'diff-deleted' : c.CHANGE_TYPE === 'changed' ? 'diff-changed' : '';
var vals = c.NEW_VALUES && Object.keys(c.NEW_VALUES).length > 0 ? c.NEW_VALUES : (c.OLD_VALUES || {});
tbl += '<tr class="' + cls + '"><td>' + (c.ROW_NUM||'') + '</td><td>' + (vals.NAME||'') + '</td><td class="num-cell">' + _dc(c,'PRICE') + '</td><td class="num-cell">' + _dc(c,'QTY') + '</td><td class="num-cell">' + _dc(c,'SUM') + '</td><td>' + (vals.DATE_START||'') + '</td></tr>';
});
tbl += '</tbody></table></div>';
diffBody.innerHTML += tbl;
}
}
} catch(e) { diffBody.innerHTML += '<div style="color:var(--destructive);">✗ ' + e.message + '</div>'; }
btn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px;"></i> ✓ Готово';
document.getElementById('chatCard').style.display = 'block';
lucide.createIcons();
});
function _dc(c, field) {
if (c.CHANGE_TYPE === 'changed' && c.OLD_VALUES && c.NEW_VALUES) {
var ov = (c.OLD_VALUES||{})[field], nv = (c.NEW_VALUES||{})[field];
if (ov !== nv && ov !== undefined)
return '<span class="diff-field-old">' + (ov||'—') + '</span> → <span class="diff-field-new">' + (nv||'—') + '</span>';
}
var v = ((c.NEW_VALUES || c.OLD_VALUES || {})[field]);
return v != null ? v : '—';
}
// ── Чат ──────────────────────────────────────────────────────
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 showInfo(i) { function showInfo(i) {
var f = fileQueue[i]; var f = fileQueue[i];
document.getElementById('modalTitle').textContent = f.name; document.getElementById('modalTitle').textContent = f.name;
var html = '<div class="kv"><span class="k">Размер</span><span class="v">' + formatSize(f.size) + '</span></div>'; var html = '<div class="kv"><span class="k">Размер</span><span class="v">' + formatSize(f.size) + '</span></div>';
html += '<div class="kv"><span class="k">Изменён</span><span class="v">' + formatDate(f.lastModified) + '</span></div>'; html += '<div class="kv"><span class="k">Изменён</span><span class="v">' + formatDate(f.lastModified) + '</span></div>';
html += '<div class="kv"><span class="k">Статус</span><span class="v">' + (f.status || '—') + '</span></div>'; html += '<div class="kv"><span class="k">doc_id</span><span class="v">' + (f.doc_id || '—') + '</span></div>';
if (f.doc_id) html += '<div class="kv"><span class="k">doc_id</span><span class="v">' + f.doc_id + '</span></div>';
if (f.element_count) html += '<div class="kv"><span class="k">Элементов</span><span class="v">' + f.element_count + '</span></div>'; if (f.element_count) html += '<div class="kv"><span class="k">Элементов</span><span class="v">' + f.element_count + '</span></div>';
document.getElementById('modalBody').innerHTML = html; document.getElementById('modalBody').innerHTML = html;
document.getElementById('modalOverlay').classList.add('open'); document.getElementById('modalOverlay').classList.add('open');
@@ -315,188 +402,22 @@ function closeModal(e) {
} }
window.showInfo = showInfo; window.showInfo = showInfo;
window.closeModal = closeModal; window.closeModal = closeModal;
window.removeFile = removeFile;
// ── Парсинг ─────────────────────────────────────────────── // ── Промпт ───────────────────────────────────────────────────
document.getElementById('btnParse').addEventListener('click', async function() {
var btn = this; btn.disabled = true; log('⏳ Парсинг...');
for (var i = 0; i < fileQueue.length; i++) {
var f = fileQueue[i];
if (!f.doc_id || f.parsed) continue;
try {
var resp = await fetch('/parser.cfm?doc_id=' + f.doc_id);
var d = await resp.json();
if (d.OK) {
f.element_count = d.ELEMENT_COUNT;
f.parsed = true;
f.status = 'parsed (' + d.ELEMENT_COUNT + ')';
log('📄 ' + f.name + ': ' + d.ELEMENT_COUNT + ' эл.');
} else { log('❌ ' + f.name + ': ' + (d.ERROR||'')); }
} catch(e) { log('❌ ' + f.name + ': ' + e.message); }
renderTable();
}
btn.disabled = false;
});
// ── LLM ───────────────────────────────────────────────────
document.getElementById('btnExtract').addEventListener('click', async function() {
var btn = this; btn.disabled = true; log('🤖 LLM-извлечение...');
// Получаем supplement_id
try {
var resp = await fetch('/api.cfm?action=query&sql=' + encodeURIComponent(
"SELECT s.id,d.filename FROM supplements s JOIN documents d ON s.document_id=d.id WHERE s.contract_id='" + contractId + "' ORDER BY s.created_at"
));
var data = await resp.json();
if (!data.OK || !data.ROWS) { log('❌ Не удалось получить допники'); btn.disabled = false; return; }
for (var i = 0; i < data.ROWS.length; i++) {
var row = data.ROWS[i];
try {
var er = await fetch('/extractor.cfm?supplement_id=' + row.id);
var ed = await er.json();
if (ed.OK) {
log('✅ ' + row.filename + ': ' + ed.ROWS_SAVED + ' строк');
} else { log('❌ ' + row.filename + ': ' + (ed.ERROR||'')); }
} catch(e) { log('❌ ' + row.filename + ': ' + e.message); }
}
} catch(e) { log('❌ ' + e.message); }
btn.disabled = false;
});
// ── Сравнение ─────────────────────────────────────────────
document.getElementById('btnDiffer').addEventListener('click', async function() {
var btn = this; btn.disabled = true;
try {
var resp = await fetch('/differ.cfm?contract_id=' + contractId);
var data = await resp.json();
if (data.OK) {
var s = data.SUMMARY;
log('🔍 Изменено: ' + (s.CHANGED||0) + ', Добавлено: ' + (s.ADDED||0) + ', Удалено: ' + (s.DELETED||0));
var html = '<p style="margin-bottom:8px;"><b>Изменено:</b> ' + (s.CHANGED||0) + ' <b>Добавлено:</b> ' + (s.ADDED||0) + ' <b>Удалено:</b> ' + (s.DELETED||0) + ' <b>Без изменений:</b> ' + (s.UNCHANGED||0) + '</p>';
html += '<table><tr><th>#</th><th>Тип</th><th>Услуга</th><th>Цена</th><th>Кол-во</th><th>Сумма</th></tr>';
for (var i = 0; i < data.CHANGES.length; i++) {
var c = data.CHANGES[i];
var cls = c.CHANGE_TYPE === 'added' ? 'diff-added' : (c.CHANGE_TYPE === 'deleted' ? 'diff-deleted' : (c.CHANGE_TYPE === 'changed' ? 'diff-changed' : ''));
var vals = c.NEW_VALUES && Object.keys(c.NEW_VALUES).length > 0 ? c.NEW_VALUES : c.OLD_VALUES;
html += '<tr class="' + cls + '"><td>' + c.ROW_NUM + '</td><td>' + c.CHANGE_TYPE + '</td><td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + (vals.NAME||'') + '</td><td>' + (vals.PRICE||'') + '</td><td>' + (vals.QTY||'') + '</td><td>' + (vals.SUM||'') + '</td></tr>';
}
html += '</table>';
document.getElementById('diffBody').innerHTML = html;
document.getElementById('diffCard').style.display = 'block';
} else { log('❌ ' + (data.ERROR||'')); }
} catch(e) { log('❌ ' + e.message); }
btn.disabled = false;
});
// ── Полный цикл: парсинг → LLM → сравнение ────────────────
document.getElementById('btnFull').addEventListener('click', async function() {
var btn = this; btn.disabled = true; log('⚡ Полный цикл...');
// 1. Парсинг
for (var i = 0; i < fileQueue.length; i++) {
var f = fileQueue[i];
if (!f.doc_id || f.parsed) continue;
try {
var resp = await fetch('/parser.cfm?doc_id=' + f.doc_id);
var d = await resp.json();
if (d.OK) { f.element_count = d.ELEMENT_COUNT; f.parsed = true; f.status = 'parsed (' + d.ELEMENT_COUNT + ')'; log('📄 ' + f.name + ': ' + d.ELEMENT_COUNT + ' эл.'); }
else { log('❌ ' + f.name + ': ' + (d.ERROR||'')); }
} catch(e) { log('❌ ' + f.name + ': ' + e.message); }
renderTable();
}
// 2. LLM-извлечение
try {
var resp = await fetch('/api.cfm?action=query&sql=' + encodeURIComponent("SELECT s.id,d.filename FROM supplements s JOIN documents d ON s.document_id=d.id WHERE s.contract_id='" + contractId + "' ORDER BY s.created_at"));
var data = await resp.json();
if (data.OK && data.ROWS) {
for (var i = 0; i < data.ROWS.length; i++) {
var row = data.ROWS[i];
try {
var er = await fetch('/extractor.cfm?supplement_id=' + row.id);
var ed = await er.json();
if (ed.OK) { log('🤖 ' + row.filename + ': ' + ed.ROWS_SAVED + ' строк'); }
else { log('❌ ' + row.filename + ': ' + (ed.ERROR||'')); }
} catch(e) { log('❌ ' + row.filename + ': ' + e.message); }
}
}
} catch(e) { log('❌ ' + e.message); }
// 3. Сравнение
try {
var resp = await fetch('/differ.cfm?contract_id=' + contractId);
var data = await resp.json();
if (data.OK) {
var s = data.SUMMARY;
log('🔍 Изменено: ' + (s.CHANGED||0) + ', Добавлено: ' + (s.ADDED||0) + ', Удалено: ' + (s.DELETED||0));
var html = '<p style="margin-bottom:8px;"><b>Изменено:</b> ' + (s.CHANGED||0) + ' <b>Добавлено:</b> ' + (s.ADDED||0) + ' <b>Удалено:</b> ' + (s.DELETED||0) + ' <b>Без изменений:</b> ' + (s.UNCHANGED||0) + '</p>';
html += '<table><tr><th>#</th><th>Тип</th><th>Услуга</th><th>Цена</th><th>Кол-во</th><th>Сумма</th></tr>';
for (var i = 0; i < data.CHANGES.length; i++) {
var c = data.CHANGES[i];
var cls = c.CHANGE_TYPE === 'added' ? 'diff-added' : (c.CHANGE_TYPE === 'deleted' ? 'diff-deleted' : (c.CHANGE_TYPE === 'changed' ? 'diff-changed' : ''));
var vals = c.NEW_VALUES && Object.keys(c.NEW_VALUES).length > 0 ? c.NEW_VALUES : c.OLD_VALUES;
html += '<tr class="' + cls + '"><td>' + c.ROW_NUM + '</td><td>' + c.CHANGE_TYPE + '</td><td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + (vals.NAME||'') + '</td><td>' + (vals.PRICE||'') + '</td><td>' + (vals.QTY||'') + '</td><td>' + (vals.SUM||'') + '</td></tr>';
}
html += '</table>';
document.getElementById('diffBody').innerHTML = html;
document.getElementById('diffCard').style.display = 'block';
} else { log('❌ ' + (data.ERROR||'')); }
} catch(e) { log('❌ ' + e.message); }
log('✅ Полный цикл завершён');
btn.disabled = false;
});
// ── Промпт ─────────────────────────────────────────────────
var promptEditor = document.getElementById('promptEditor'); var promptEditor = document.getElementById('promptEditor');
// Загружаем промпт с сервера при появлении contract_id var DEFAULT_PROMPT = promptEditor.value = localStorage.getItem('llm_prompt') || '';
var origUploadNext = null;
(function() {
var checkPrompt = setInterval(function() {
if (contractId) {
clearInterval(checkPrompt);
fetch('/prompt.cfm?contract_id=' + contractId)
.then(function(r) { return r.json(); })
.then(function(d) { if (d.OK) promptEditor.value = d.PROMPT || ''; });
}
}, 500);
})();
// Автосохранение на сервер при изменении
var promptTimer = null;
promptEditor.addEventListener('input', function() {
clearTimeout(promptTimer);
promptTimer = setTimeout(function() {
if (!contractId) return;
fetch('/prompt.cfm?contract_id=' + contractId, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({prompt: promptEditor.value})
});
}, 800);
});
// ── Чат ─────────────────────────────────────────────────── document.getElementById('promptSave').addEventListener('click', function() {
document.getElementById('chatSend').addEventListener('click', async function() { var p = promptEditor.value.trim();
var input = document.getElementById('chatInput'); localStorage.setItem('llm_prompt', p);
var q = input.value.trim(); document.getElementById('promptStatus').textContent = '✓ сохранено';
if (!q || !contractId) return; setTimeout(function() { document.getElementById('promptStatus').textContent = ''; }, 1500);
var msgs = document.getElementById('chatMessages');
msgs.innerHTML += '<div style="margin-bottom:4px;"><b>Вы:</b> ' + q + '</div>';
input.value = '';
document.getElementById('chatSend').disabled = true;
try {
var fd = new FormData();
fd.append('question', q);
var resp = await fetch('/chat.cfm?contract_id=' + contractId, { method: 'POST', body: fd });
var data = await resp.json();
if (data.OK) {
msgs.innerHTML += '<div style="margin-bottom:8px;"><b>AI:</b> ' + data.ANSWER + '</div>';
} else {
msgs.innerHTML += '<div style="color:var(--destructive);margin-bottom:8px;">Ошибка: ' + (data.ERROR||'') + '</div>';
}
} catch(e) {
msgs.innerHTML += '<div style="color:var(--destructive);margin-bottom:8px;">Сеть: ' + e.message + '</div>';
}
document.getElementById('chatSend').disabled = false;
}); });
document.getElementById('chatInput').addEventListener('keydown', function(e) { document.getElementById('promptReset').addEventListener('click', function() {
if (e.key === 'Enter') document.getElementById('chatSend').click(); promptEditor.value = DEFAULT_PROMPT;
localStorage.removeItem('llm_prompt');
document.getElementById('promptStatus').textContent = '✓ сброшен';
setTimeout(function() { document.getElementById('promptStatus').textContent = ''; }, 1500);
}); });
</script> </script>
</body> </body>