745 lines
37 KiB
Plaintext
745 lines
37 KiB
Plaintext
<cfsetting showdebugoutput="no" enablecfoutputonly="no">
|
||
<cfheader name="Content-Type" value="text/html; charset=utf-8">
|
||
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Сверка договоров — LLM</title>
|
||
<link rel="icon" type="image/png" href="/favicon.png">
|
||
<script src="https://unpkg.com/lucide@latest"></script>
|
||
<style>
|
||
: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; }
|
||
* { 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; }
|
||
.topbar { background: var(--background); border-bottom: 1px solid var(--brand-gray); padding: 0 24px; height: 56px; display: flex; align-items: center; gap: 16px; position: sticky; top: 0; z-index: 50; }
|
||
.topbar img { height: 28px; }
|
||
.topbar .title { font-weight: 600; font-size: 16px; }
|
||
.content { max-width: 900px; margin: 32px auto; padding: 0 16px; }
|
||
.card { background: var(--background); border-radius: 12px; border: 1px solid var(--brand-gray); box-shadow: 0 1px 2px rgba(0,0,0,.05); overflow: hidden; margin-bottom: 16px; }
|
||
.card-header { background: var(--brand-grey-light); padding: 12px 16px; font-weight: 600; font-size: 16px; display: flex; align-items: center; gap: 8px; }
|
||
.card-body { padding: 16px; }
|
||
.btn { height: 36px; border-radius: 6px; gap: 6px; padding: 0 14px; font-size: 14px; font-family: inherit; cursor: pointer; display: inline-flex; align-items: center; border: 1px solid var(--brand-gray); background: var(--background); }
|
||
.btn-primary { background: var(--brand-primary); border-color: var(--brand-primary); color: #fff; }
|
||
.btn-primary:hover { background: var(--brand-primary-dark); }
|
||
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||
.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; }
|
||
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); }
|
||
td { padding: 6px 10px; border-right: 1px solid var(--brand-gray); border-bottom: 1px solid var(--brand-gray); }
|
||
tr:hover td { background: rgba(243,244,246,.5); }
|
||
.name-cell { max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.num-cell { text-align: right; white-space: nowrap; }
|
||
.status-ok { color: var(--green); }
|
||
.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; }
|
||
.remove-btn { cursor: pointer; color: #f87171; background: none; border: none; padding: 2px 4px; font-size: 16px; line-height: 1; }
|
||
.remove-btn:hover { color: #ef4444; }
|
||
.info-btn { cursor: pointer; color: var(--muted); background: none; border: none; padding: 2px 4px; font-size: 14px; display: none; }
|
||
.info-btn:hover { color: var(--brand-primary); }
|
||
.info-btn.visible { display: inline; }
|
||
.first-row td { background: rgba(37,99,235,.06); font-weight: 600; }
|
||
.arrow-btn { cursor: pointer; color: var(--muted); background: none; border: none; padding: 2px; font-size: 12px; line-height: 1; }
|
||
.arrow-btn:hover { color: var(--brand-primary); }
|
||
.arrow-btn:disabled { opacity: .3; cursor: default; color: var(--muted); }
|
||
.modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,.4); z-index: 100; justify-content: center; align-items: center; }
|
||
.modal-overlay.open { display: flex; }
|
||
.modal { background: var(--background); border-radius: 12px; border: 1px solid var(--brand-gray); box-shadow: 0 4px 24px rgba(0,0,0,.15); max-width: 520px; width: 90%; max-height: 80vh; display: flex; flex-direction: column; }
|
||
.modal.wide { max-width: 900px; }
|
||
.modal-header { padding: 14px 16px; border-bottom: 1px solid var(--brand-gray); display: flex; align-items: center; gap: 8px; font-weight: 600; flex-shrink: 0; }
|
||
.modal-body { padding: 16px; overflow-y: auto; }
|
||
.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 .v { word-break: break-all; }
|
||
.diff-added { background: rgba(34,197,94,.1); }
|
||
.diff-deleted { background: rgba(239,68,68,.1); }
|
||
.diff-changed { background: rgba(234,179,8,.1); }
|
||
.diff-added td { color: var(--green); }
|
||
.diff-deleted td { color: var(--destructive); text-decoration: line-through; }
|
||
.diff-field-old { color: var(--destructive); text-decoration: line-through; font-size: 11px; }
|
||
.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; }
|
||
.text-panes { display: flex; gap: 0; }
|
||
.text-pane { flex: 1; min-width: 0; overflow-y: auto; max-height: 65vh; }
|
||
.text-pane:first-child { border-right: 1px solid var(--brand-gray); padding-right: 12px; }
|
||
.text-pane:last-child { padding-left: 12px; }
|
||
.text-pane pre { background: var(--brand-grey-light); padding: 10px; border-radius: 6px; font-size: 11px; white-space: pre-wrap; word-break: break-word; overflow-x: auto; margin: 0; max-height: 60vh; overflow-y: auto; }
|
||
.text-pane table { font-size: 11px; width: 100%; }
|
||
.text-pane th { font-size: 10px; padding: 4px 6px; }
|
||
.text-pane td { padding: 3px 6px; font-size: 11px; }
|
||
.text-pane .pane-title { font-weight: 600; font-size: 12px; margin-bottom: 8px; color: var(--muted); text-transform: uppercase; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="topbar">
|
||
<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.115 — Lucee</span></span>
|
||
</div>
|
||
|
||
<div class="content">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<i data-lucide="folder-open" style="width:18px;height:18px;"></i>
|
||
Загрузка договоров/приложений/спецификаций
|
||
</div>
|
||
<div class="card-body">
|
||
<input type="file" id="fileInput" accept=".docx,.doc,.pdf,.zip" multiple style="margin-bottom:12px;width:100%;">
|
||
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr><th>Имя</th><th style="width:130px;">Изменён</th><th style="width:90px;">Размер</th><th style="width:115px;">Парсинг</th><th style="width:30px;">↕</th><th style="width:30px;">Текст</th><th style="width:30px;">✕</th></tr>
|
||
</thead>
|
||
<tbody id="fileTable"></tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<button class="btn btn-primary" id="llmBtn" style="width:100%;justify-content:center;margin-top:8px;display:none;background:var(--green);border-color:var(--green);">
|
||
<i data-lucide="scale" style="width:16px;height:16px;"></i> Сравнить
|
||
</button>
|
||
|
||
<details style="margin-top:8px;font-size:12px;">
|
||
<summary style="cursor:pointer;color:var(--muted);">⚙ Промпты LLM</summary>
|
||
<div style="display:flex;gap:8px;margin-top:6px;">
|
||
<button class="btn" id="promptBtnExtract" style="font-size:11px;height:28px;flex:1;">📄 Извлечение</button>
|
||
<button class="btn" id="promptBtnDiff" style="font-size:11px;height:28px;flex:1;">🔄 Сравнение</button>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" id="diffCard" style="display:none;">
|
||
<div class="card-header">
|
||
<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 class="card-body" id="diffBody"></div>
|
||
</div>
|
||
|
||
<div class="card" id="chatCard" style="display:none;margin-top:16px;">
|
||
<div class="card-header">
|
||
<i data-lucide="message-circle" style="width:18px;height:18px;"></i>
|
||
Задать вопрос по договору
|
||
</div>
|
||
<div class="card-body">
|
||
<div id="chatMessages" style="margin-bottom:8px;font-size:13px;"></div>
|
||
<div style="display:flex;gap:8px;">
|
||
<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" style="height:32px;font-size:13px;">→</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-overlay" id="modalOverlay" onclick="closeModal(event)">
|
||
<div class="modal" onclick="event.stopPropagation()">
|
||
<div class="modal-header">
|
||
<i data-lucide="file-text" style="width:18px;height:18px;"></i>
|
||
<span id="modalTitle">Информация о файле</span>
|
||
<button class="remove-btn" onclick="closeModal()" style="margin-left:auto;font-size:18px;">×</button>
|
||
</div>
|
||
<div class="modal-body" id="modalBody"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Модалка промптов ── -->
|
||
<div class="modal-overlay" id="promptModalOverlay" onclick="closePromptModal(event)">
|
||
<div class="modal" style="max-width:680px;" onclick="event.stopPropagation()">
|
||
<div class="modal-header">
|
||
<i data-lucide="settings" style="width:18px;height:18px;"></i>
|
||
<span id="promptModalTitle">Промпт: Сравнение</span>
|
||
<button class="remove-btn" onclick="closePromptModal()" style="margin-left:auto;font-size:18px;">×</button>
|
||
</div>
|
||
<div class="modal-body" style="padding:16px;">
|
||
<div style="display:flex;gap:6px;margin-bottom:10px;">
|
||
<button class="btn" id="promptTabExtract" style="font-size:11px;height:26px;">📄 Извлечение</button>
|
||
<button class="btn" id="promptTabDiff" style="font-size:11px;height:26px;background:var(--brand-primary);color:#fff;">🔄 Сравнение</button>
|
||
</div>
|
||
<textarea id="promptEditor" style="width:100%;height:220px;font-family:monospace;font-size:11px;border:1px solid var(--brand-gray);border-radius:6px;padding:8px;box-sizing:border-box;"></textarea>
|
||
<div style="display:flex;gap:8px;margin-top:8px;align-items:center;">
|
||
<input id="promptName" placeholder="Имя версии" style="width:140px;font-size:11px;border:1px solid var(--brand-gray);border-radius:4px;padding:4px 6px;">
|
||
<input id="promptNotes" placeholder="Что изменили" style="flex:1;font-size:11px;border:1px solid var(--brand-gray);border-radius:4px;padding:4px 6px;">
|
||
<button class="btn" id="promptSave" style="font-size:11px;height:28px;background:var(--green);color:#fff;border-color:var(--green);">Сохранить</button>
|
||
</div>
|
||
<span id="promptStatus" style="font-size:11px;color:var(--muted);display:block;margin-top:4px;"></span>
|
||
<div style="margin-top:12px;font-size:11px;color:var(--muted);font-weight:600;">История версий</div>
|
||
<div id="promptHistory" style="max-height:180px;overflow-y:auto;margin-top:4px;"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
lucide.createIcons();
|
||
|
||
var UPLOAD_URL = 'https://contracts.kube5s.ru/lucee/upload.cfm';
|
||
var CONVERT_URL = 'https://contracts.kube5s.ru/convert-doc';
|
||
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 / .zip</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;
|
||
|
||
// ── Автозагрузка при выборе файлов ──────────────────────────
|
||
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 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();
|
||
});
|
||
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;
|
||
|
||
// Авто-парсинг сразу после загрузки
|
||
var t0 = Date.now();
|
||
fileQueue[rowIdx].status = '⏳ парсинг...';
|
||
renderTable();
|
||
try {
|
||
var pr = await fetch('/parser.cfm?doc_id=' + resp.DOC_ID);
|
||
var pd = await pr.json();
|
||
var elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
||
if (pd.OK && pd.STATUS === 'parsed') {
|
||
fileQueue[rowIdx].parsed = true;
|
||
fileQueue[rowIdx].parseInfo = pd;
|
||
fileQueue[rowIdx].status = '<span class="status-ok">✓ ' + pd.ELEMENT_COUNT + ' эл. (' + elapsed + 'с)</span>';
|
||
} else {
|
||
fileQueue[rowIdx].status = '<span class="status-err">✗ ' + (pd.ERROR_COUNT > 0 ? (pd.ERRORS && pd.ERRORS[0] || 'ошибка') : 'ошибка') + '</span>';
|
||
fileQueue[rowIdx].parseInfo = pd;
|
||
}
|
||
} catch(pe) {
|
||
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('/api.cfm?action=query&sql=' + encodeURIComponent("SELECT s.id, s.type, s.document_id FROM supplements s WHERE s.contract_id='" + contractId + "'"));
|
||
var data = await resp.json();
|
||
console.log('refreshSupps: data=', data);
|
||
console.log('refreshSupps: fileQueue doc_ids=', fileQueue.map(function(f){return f.doc_id;}));
|
||
if (data.OK && data.ROWS) {
|
||
data.ROWS.forEach(function(r) {
|
||
var supp = {};
|
||
data.COLUMNS.forEach(function(c, ci) { supp[String(c).toLowerCase()] = r[c]; });
|
||
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('/api.cfm?action=query&sql=SELECT%20elements_json%20FROM%20documents%20WHERE%20id%3D\'' + docId + '\'');
|
||
var qData = await qResp.json();
|
||
var elements = [];
|
||
if (qData.OK && qData.ROWS && qData.ROWS.length > 0) {
|
||
var ej = qData.ROWS[0].ELEMENTS_JSON;
|
||
if (typeof ej === 'object' && ej.Value) ej = ej.Value;
|
||
elements = JSON.parse(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>
|
||
</html>
|