584 lines
30 KiB
Plaintext
584 lines
30 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; }
|
||
.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 <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.0.90 — 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>
|
||
<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 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>
|
||
|
||
<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 radio = '';
|
||
if (f.supp_id) {
|
||
radio = '<input type="radio" name="base_doc" value="' + f.supp_id + '"' + (f.supp_type === 'initial' ? ' checked' : '') + ' onchange="setInitial(\'' + f.supp_id + '\')" title="Выбрать как базовый договор">';
|
||
}
|
||
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 style="text-align:center;">' + radio + '</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;
|
||
|
||
// ── Автозагрузка при выборе файлов ──────────────────────────
|
||
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 es = new EventSource('https://contracts.kube5s.ru/process-v2?contract_id=' + contractId);
|
||
|
||
es.onmessage = function(e) {
|
||
var d = JSON.parse(e.data);
|
||
|
||
if (d.type === 'extract_start') {
|
||
diffBody.innerHTML += '<div style="font-size:12px;color:var(--muted);margin-top:2px;">⏳ ' + d.filename + ' → LLM...</div>';
|
||
}
|
||
else if (d.type === 'llm_done') {
|
||
diffBody.innerHTML += '<div style="font-size:12px;color:var(--green);">✓ ' + d.filename + ': ' + d.ops_count + ' операций, ' + d.mode + ' (' + d.time_s + 'с)</div>';
|
||
}
|
||
else if (d.type === 'applied') {
|
||
var s = d.summary || {};
|
||
diffBody.innerHTML += '<div style="font-size:12px;margin-left:8px;"> +' + (s.added||0) + ' ~' + (s.updated||0) + ' -' + (s.deleted||0) + ' ?' + (s.unresolved||0) + '</div>';
|
||
}
|
||
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> Сравнить v2';
|
||
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); }
|
||
}
|
||
|
||
function setInitial(suppId) {
|
||
fetch('/api.cfm?action=set_initial&supplement_id=' + suppId)
|
||
.then(function() { refreshSupps(); });
|
||
}
|
||
window.setInitial = setInitial;
|
||
|
||
// ── Чат ──────────────────────────────────────────────────────
|
||
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>';
|
||
rightHtml += '<div class="kv" style="margin-top:10px;"><span class="k">doc_id</span><span class="v">' + (f.doc_id || '—') + '</span></div>';
|
||
if (f.supp_id) {
|
||
rightHtml += '<div class="kv"><span class="k">supp_id</span><span class="v">' + f.supp_id + '</span></div>';
|
||
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 DEFAULT_PROMPT = 'Ты — анализатор договоров. Ниже текст спецификации услуг из договора облачного провайдера.\n\nНайди ВСЕ таблицы со списком услуг. Извлеки каждую строку в JSON-массив.\n\nДля каждой строки верни:\n- row_num: номер по порядку (целое число)\n- name: полное наименование услуги (весь текст из ячейки)\n- price: цена за единицу в рублях (число, из колонки "Цена")\n- qty: количество/объём (число, из колонки "Объем")\n- sum: итоговая сумма (число, из колонки "Сумма")\n- date_start: дата начала оказания (строка YYYY-MM-DD)\n\nПРАВИЛА:\n1. Пропускай итоговые строки ("Итого...") и строки с подписями.\n2. Если ячейка пустая — ставь null.\n3. Если не можешь определить значение — ставь null и добавь в unresolved.\n4. Верни ТОЛЬКО JSON, без пояснений.\n\nПример ответа:\n{"rows":[{"row_num":1,"name":"Аренда стойко-места","price":213905.44,"qty":3,"sum":641716.32,"date_start":"2026-04-01"}],"unresolved":[]}\n\nТекст документа:\n---\n{parsed_text}\n---';
|
||
promptEditor.value = localStorage.getItem('llm_prompt') || DEFAULT_PROMPT;
|
||
|
||
document.getElementById('promptSave').addEventListener('click', function() {
|
||
var p = promptEditor.value.trim();
|
||
localStorage.setItem('llm_prompt', p);
|
||
document.getElementById('promptStatus').textContent = '✓ сохранено';
|
||
setTimeout(function() { document.getElementById('promptStatus').textContent = ''; }, 1500);
|
||
if (contractId) {
|
||
fetch('/prompt.cfm?contract_id=' + contractId, {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({prompt: p})
|
||
});
|
||
}
|
||
});
|
||
document.getElementById('promptReset').addEventListener('click', function() {
|
||
promptEditor.value = DEFAULT_PROMPT;
|
||
localStorage.removeItem('llm_prompt');
|
||
document.getElementById('promptStatus').textContent = '✓ сброшен';
|
||
setTimeout(function() { document.getElementById('promptStatus').textContent = ''; }, 1500);
|
||
if (contractId) {
|
||
fetch('/prompt.cfm?contract_id=' + contractId, {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({prompt: ''})
|
||
});
|
||
}
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|