Files
contracts-app/site/templates/llm.html
T

674 lines
32 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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="{{ url_for('static', filename='favicon.png') }}">
<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>
: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; }
.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; }
.zip-child .name-cell { padding-left: 28px; }
.zip-child .name-cell::before { content: "└ "; color: var(--muted); }
.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: var(--muted); background: none; border: none; padding: 2px 4px; font-size: 16px; line-height: 1; }
.remove-btn:hover { color: var(--destructive); }
.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-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; }
.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-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; }
</style>
</head>
<body>
<div class="topbar">
<img src="{{ url_for('static', filename='nubes-logo.svg') }}" alt="Nubes">
<span class="title">Сверка договоров — LLM <span style="font-weight:400;color:var(--muted);font-size:12px;">v3.2.0</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></tr>
</thead>
<tbody id="fileTable"></tbody>
</table>
</div>
<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 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;" placeholder="Промпт для LLM..."></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();
const DEFAULT_PROMPT = `Ты — анализатор договоров. Ниже текст спецификации услуг из договора облачного провайдера.
Найди ВСЕ таблицы со списком услуг. Извлеки каждую строку в JSON-массив.
Для каждой строки верни:
- row_num: номер по порядку (целое число)
- name: полное наименование услуги (весь текст из ячейки)
- price: цена за единицу в рублях (число, из колонки "Цена")
- qty: количество/объём (число, из колонки "Объем")
- sum: итоговая сумма (число, из колонки "Сумма")
- date_start: дата начала оказания (строка YYYY-MM-DD)
ПРАВИЛА:
1. Пропускай итоговые строки ("Итого...") и строки с подписями.
2. Если ячейка пустая — ставь null.
3. Если не можешь определить значение — ставь null и добавь в unresolved.
4. Верни ТОЛЬКО JSON, без пояснений.
Пример ответа:
{"rows":[{"row_num":1,"name":"Аренда стойко-места","price":213905.44,"qty":3,"sum":641716.32,"date_start":"2026-04-01"}],"unresolved":[]}
Текст документа:
---
{parsed_text}
---`;
const fileInput = document.getElementById('fileInput');
const parseBtn = document.getElementById('parseBtn');
const fileTable = document.getElementById('fileTable');
let fileQueue = [];
let 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="6">Нет файлов — выберите .docx / .pdf / .zip</td></tr>';
parseBtn.disabled = true;
} else {
fileTable.innerHTML = fileQueue.map(function(f, i) {
var cls = f.isZipChild ? 'zip-child' : '';
return '<tr class="' + cls + '" id="row_' + i + '">' +
'<td class="name-cell">' + f.name + '</td>' +
'<td style="font-size:12px;color:var(--muted);">' + formatDate(f.lastModified) + '</td>' +
'<td class="num-cell" style="font-size:12px;color:var(--muted);">' + formatSize(f.size) + '</td>' +
'<td class="status-cell">' + (f.status || '') + '</td>' +
'<td><button class="info-btn" 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>' +
'</tr>';
}).join('');
}
}
function removeFile(index) {
var f = fileQueue[index];
if (!f) return;
if (!f.isZipChild && (f.name||'').toLowerCase().endsWith('.zip')) {
for (var i = fileQueue.length - 1; i >= 0; i--) {
if (fileQueue[i].zipName === f.name) fileQueue.splice(i, 1);
}
}
fileQueue.splice(index, 1);
if (fileQueue.filter(function(x){return !x.isZipChild;}).length === 0) contractId = null;
renderTable();
}
window.removeFile = removeFile;
// ── Простая загрузка FormData (ВМ, без лимитов) ──────────
function uploadFile(file, onProgress) {
return new Promise(function(resolve, reject) {
var fd = new FormData();
fd.append('files', file);
if (contractId) fd.append('cid', contractId);
var xhr = new XMLHttpRequest();
xhr.open('POST', '/');
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.contract_id) 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 = 120000;
xhr.send(fd);
});
}
// ── Выбор файла → сразу загрузка ────────────────────────────
fileInput.addEventListener('change', async function() {
var newFiles = Array.from(fileInput.files);
if (newFiles.length === 0) return;
fileInput.disabled = true;
parseBtn.disabled = true;
parseBtn.innerHTML = '<span style="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;"></span> Загрузка...';
for (var i = 0; i < newFiles.length; i++) {
var f = newFiles[i];
if (fileQueue.find(function(q) { return q.name === f.name && q.size === f.size && !q.isZipChild; })) continue;
var entry = { name: f.name, lastModified: f.lastModified, size: f.size, status: '<span style="color:var(--muted);">↑ 0%</span>' };
fileQueue.push(entry);
var rowIdx = fileQueue.length - 1;
renderTable();
var startTime = Date.now();
try {
var resp = await uploadFile(f, function(pct) {
fileQueue[rowIdx].status = '<span style="color:var(--muted);">↑ ' + pct + '%</span>';
renderTable();
});
if (resp && resp.contract_id) contractId = resp.contract_id;
var elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
fileQueue[rowIdx].status = '<span class="status-ok">✓ ' + elapsed + 'с</span>';
fileQueue[rowIdx].uploaded = true;
renderTable();
if (f.type === 'application/zip' || f.name.toLowerCase().endsWith('.zip')) {
await showZipContents(f);
}
} catch(err) {
fileQueue[rowIdx].status = '<span class="status-err">✗ ' + err.message + '</span>';
renderTable();
}
}
fileInput.value = '';
fileInput.disabled = false;
parseBtn.disabled = (contractId === null);
parseBtn.innerHTML = '<i data-lucide="play" style="width:16px;height:16px;"></i> Парсинг';
lucide.createIcons();
});
async function showZipContents(zipFile) {
try {
var zip = await JSZip.loadAsync(zipFile);
zip.forEach(function(relativePath, zipEntry) {
if (!zipEntry.dir) {
var ext = relativePath.toLowerCase().split('.').pop();
if (['docx','doc','pdf'].indexOf(ext) === -1) return;
var zsize = (zipEntry._data && zipEntry._data.uncompressedSize) ? zipEntry._data.uncompressedSize : 0;
fileQueue.push({
name: relativePath,
lastModified: zipEntry.date ? zipEntry.date.getTime() : 0,
size: zsize,
status: '',
isZipChild: true,
zipName: zipFile.name
});
}
});
renderTable();
} catch(e) {
console.log('JSZip error:', e);
}
}
// ── Парсинг (SSE) ────────────────────────────────────────────
parseBtn.addEventListener('click', function() {
if (!contractId) return;
parseBtn.disabled = true;
parseBtn.innerHTML = '<span style="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;"></span> Парсинг...';
var es = new EventSource('/parse/' + contractId);
var timerInterval = setInterval(function() {
var now = Date.now();
document.querySelectorAll('.proc-timer').forEach(function(el) {
var start = parseInt(el.dataset.start);
if (start) el.textContent = '⏳ ' + ((now - start) / 1000).toFixed(1) + 'с';
});
}, 500);
es.onmessage = function(e) {
var d = JSON.parse(e.data);
if (d.type === 'file_start') {
var row = findRowByName(d.name);
if (row) {
row.querySelector('.status-cell').innerHTML = '<span class="proc-timer" data-start="' + Date.now() + '">⏳ 0.0с</span>';
if (d.bytes > 0) {
var sizeCell = row.querySelector('.num-cell');
if (sizeCell && sizeCell.textContent.trim() === '—') {
sizeCell.textContent = formatSize(d.bytes);
}
}
}
}
else if (d.type === 'file_done') {
var row = findRowByName(d.name);
if (row) {
row.querySelector('.status-cell').innerHTML = '<span class="status-ok">✓ ' + d.time_s + 'с</span>';
var idx = findIndexByName(d.name);
if (idx >= 0) { fileQueue[idx].parseInfo = d; }
var infoBtn = document.getElementById('info_' + (idx >= 0 ? idx : ''));
if (infoBtn) infoBtn.classList.add('visible');
}
}
else if (d.type === 'file_error') {
var row = findRowByName(d.name);
if (row) {
row.querySelector('.status-cell').innerHTML = '<span class="status-err">✗ ' + d.error + '</span>';
}
}
else if (d.type === 'zip_expanded') {
var row = findRowByName(d.name);
if (row) {
row.querySelector('.status-cell').innerHTML = '<span style="color:var(--muted);">↗ ' + d.count + ' файлов</span>';
}
}
else if (d.type === 'summary') {
clearInterval(timerInterval);
es.close();
parseBtn.disabled = true;
parseBtn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px;"></i> ✓ Готово';
var llmBtn = document.getElementById('llmBtn');
llmBtn.style.display = 'block';
llmBtn.disabled = false;
lucide.createIcons();
var summaryRow = document.createElement('tr');
summaryRow.className = 'summary-row';
summaryRow.innerHTML = '<td>✓ Готово: ' + d.files_processed + ' файлов</td>' +
'<td></td>' +
'<td class="num-cell">' + formatSize(d.total_bytes) + '</td>' +
'<td><strong>' + d.total_time_s + 'с</strong></td>' +
'<td></td><td></td>';
fileTable.appendChild(summaryRow);
}
};
es.addEventListener('error', function() {
clearInterval(timerInterval);
es.close();
parseBtn.disabled = false;
parseBtn.innerHTML = '<i data-lucide="play" style="width:16px;height:16px;"></i> Парсинг';
lucide.createIcons();
});
function findRowByName(name) {
for (var i = 0; i < fileQueue.length; i++) {
if (fileQueue[i].name === name) {
return document.getElementById('row_' + i);
}
}
return null;
}
function findIndexByName(name) {
for (var i = 0; i < fileQueue.length; i++) {
if (fileQueue[i].name === name) return i;
}
return -1;
}
});
// ── Модальное окно ──────────────────────────────────────────
function showInfo(index) {
var f = fileQueue[index];
if (!f || !f.parseInfo) return;
var d = f.parseInfo;
document.getElementById('modalTitle').textContent = f.name;
var rows = [
['Размер', formatSize(f.size)],
['В архиве', f.zipName || '—'],
['Время парсинга', d.time_s + 'с'],
['Элементов всего', d.elements],
['Параграфов', d.paragraphs],
['Таблиц', d.tables],
['Строк таблиц', d.table_rows],
['Текст (символов)', d.text_len],
];
var html = rows.map(function(r) {
return '<div class="kv"><span class="k">' + r[0] + '</span><span class="v">' + r[1] + '</span></div>';
}).join('');
if (d.errors && d.errors.length > 0) {
html += '<div style="margin-top:8px;color:var(--destructive);font-weight:600;">Ошибки парсера:</div>';
html += '<pre>' + d.errors.join('\n') + '</pre>';
}
if (d.text_preview) {
html += '<div style="margin-top:10px;font-weight:600;font-size:13px;">Превью текста:</div>';
html += '<pre>' + d.text_preview + '</pre>';
}
if (d.table_headers && d.table_headers.length > 0) {
html += '<div style="margin-top:10px;font-weight:600;font-size:13px;">Первые строки таблиц:</div>';
d.table_headers.forEach(function(hdr, ti) {
html += '<div style="margin-top:4px;font-size:11px;color:var(--muted);">Таблица ' + (ti+1) + ':</div>';
html += '<pre>' + hdr.map(function(c) { return c || '(пусто)'; }).join(' | ') + '</pre>';
});
}
document.getElementById('modalBody').innerHTML = html;
document.getElementById('modalOverlay').classList.add('open');
}
function closeModal(e) {
if (e && e.target !== document.getElementById('modalOverlay')) return;
document.getElementById('modalOverlay').classList.remove('open');
}
window.showInfo = showInfo;
window.closeModal = closeModal;
// ── LLM: Сравнить ────────────────────────────────────────────
document.getElementById('llmBtn').addEventListener('click', function() {
if (!contractId) return;
// Сохранить промпт на сервер перед стартом
var p = promptEditor.value.trim() || DEFAULT_PROMPT;
fetch('/llm/prompt/' + contractId, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({prompt: p})
});
var llmBtn = document.getElementById('llmBtn');
llmBtn.disabled = true;
llmBtn.innerHTML = '<span style="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;"></span> LLM-анализ...';
var diffCard = document.getElementById('diffCard');
var diffBody = document.getElementById('diffBody');
var diffStatus = document.getElementById('diffStatus');
diffCard.style.display = 'block';
diffBody.innerHTML = '<span style="color:var(--muted);">⏳ LLM-извлечение строк спецификации...</span>';
var es = new EventSource('/llm/process/' + contractId);
var llmStart = Date.now();
var timerInterval = setInterval(function() {
var elapsed = ((Date.now() - llmStart) / 1000).toFixed(1);
diffStatus.textContent = '⏳ ' + elapsed + 'с';
}, 200);
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.name + '</div>';
}
else if (d.type === 'extract_done') {
diffStatus.textContent = '✓ ' + d.count + ' строк из ' + d.name;
diffBody.innerHTML += '<div style="font-size:12px;color:var(--green);">✓ ' + d.name + ': ' + d.count + ' строк (' + d.time_s + 'с)</div>';
}
else if (d.type === 'extract_skip') {
diffBody.innerHTML += '<div style="font-size:12px;color:var(--muted);">⏭ ' + d.name + ' (уже извлечено)</div>';
}
else if (d.type === 'extract_error') {
diffBody.innerHTML += '<div style="font-size:12px;color:var(--destructive);">✗ ' + d.name + ': ' + d.error + '</div>';
}
else if (d.type === 'diff_start') {
diffBody.innerHTML += '<div style="margin-top:8px;font-weight:600;">📊 Сравнение:</div>';
}
else if (d.type === 'diff_file') {
var s = d.summary || {};
diffBody.innerHTML += '<div style="font-size:12px;margin-left:8px;">' + d.name + ': <span style="color:var(--green);">+' + (s.added||0) + '</span> <span style="color:#eab308;">~' + (s.changed||0) + '</span> <span style="color:var(--destructive);">-' + (s.deleted||0) + '</span></div>';
}
else if (d.type === 'done') {
clearInterval(timerInterval);
es.close();
llmBtn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px;"></i> ✓ Готово';
lucide.createIcons();
diffStatus.textContent = '✓ ' + d.total_time_s + 'с';
// Показать чат
document.getElementById('chatCard').style.display = 'block';
lucide.createIcons();
var all = d.all_changes || [];
if (all.length === 0) {
diffBody.innerHTML += '<div style="color:var(--muted);margin-top:8px;">Нет допников для сравнения.</div>';
return;
}
all.forEach(function(ch) {
diffBody.innerHTML += '<div style="font-weight:600;margin-top:12px;">📄 ' + ch.filename + ' (' + ch.type + ')</div>';
if (!ch.changes || ch.changes.length === 0) {
diffBody.innerHTML += '<div style="color:var(--muted);font-size:12px;">Нет изменений</div>';
return;
}
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>';
ch.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 || 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;
});
}
else if (d.type === 'error') {
diffBody.innerHTML += '<div style="color:var(--destructive);margin-top:8px;">✗ ' + d.message + '</div>';
}
};
es.addEventListener('error', function() {
clearInterval(timerInterval);
es.close();
llmBtn.innerHTML = '<i data-lucide="scale" style="width:16px;height:16px;"></i> Сравнить (LLM)';
llmBtn.disabled = false;
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>';
fetch('/llm/chat/' + contractId, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({question: q})
}).then(function(r) { return r.json(); })
.then(function(d) {
msgs.lastChild.remove(); // убрать "Думаю..."
msgs.innerHTML += '<div style="margin-top:2px;"><strong>Ответ:</strong> ' + (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();
});
// ── Редактор промпта ──────────────────────────────────────────
var promptEditor = document.getElementById('promptEditor');
// Загрузить из localStorage
var saved = localStorage.getItem('llm_prompt');
promptEditor.value = saved || DEFAULT_PROMPT;
document.getElementById('promptSave').addEventListener('click', function() {
var p = promptEditor.value.trim();
if (!p) return;
localStorage.setItem('llm_prompt', p);
document.getElementById('promptStatus').textContent = '✓ сохранено';
setTimeout(function() { document.getElementById('promptStatus').textContent = ''; }, 1500);
// Отправить на сервер
if (contractId) {
fetch('/llm/prompt/' + 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);
});
// При старте LLM — сохранить промпт для этого contractId
var origLlmClick = document.getElementById('llmBtn').onclick;
// Переопределим ниже...
var style = document.createElement('style');
style.textContent = '@keyframes spin { to { transform: rotate(360deg); } }';
document.head.appendChild(style);
</script>
</body>
</html>