624 lines
29 KiB
JavaScript
624 lines
29 KiB
JavaScript
// ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔
|
||
// Contracts App — весь JS на VM (contracts.kube5s.ru)
|
||
// Lucee: только домен + index.cfm-скелет
|
||
var VM_API = 'https://check.kube5s.ru';
|
||
var UPLOAD_URL = VM_API + '/upload';
|
||
var CONVERT_URL = VM_API + '/convert-doc';
|
||
var UNZIP_URL = VM_API + '/unzip-upload';
|
||
// SITE_URL удалён (Фаза 4) — не использовался
|
||
|
||
var fileInput = document.getElementById('fileInput');
|
||
var fileTable = document.getElementById('fileTable');
|
||
// state.files — теперь в state.js (Фаза 0: state + render)
|
||
// state.contractId — теперь в state.js (Фаза 0: state + render)
|
||
// state.batchId — теперь в state.js (Фаза 0: state + render)
|
||
// (batchId = crypto.randomUUID() — уникальный ID сессии для классификации)
|
||
|
||
// Автоочистка старых записей при загрузке страницы
|
||
(async function cleanup() {
|
||
try {
|
||
await fetch(VM_API + '/api/cleanup', { method: 'POST' });
|
||
} catch(e) { /* ignore */ }
|
||
})();
|
||
|
||
/**
|
||
* render(state) — Главная функция рендеринга (Фаза 0, decoupling-final-plan.md).
|
||
*
|
||
* ПАТТЕРН: action → мутация state → render(state)
|
||
*
|
||
* Фаза 0: вызывает существующий renderTable().
|
||
* renderTable читает state.files напрямую (state — глобальный объект).
|
||
* Фаза 1+: будет вызывать renderFiles(state), renderGroups(state), renderStepper(state) и т.д.
|
||
*
|
||
* ПРАВИЛО: любое изменение state ДОЛЖНО завершаться вызовом render(state).
|
||
* Никакой прямой манипуляции DOM в обход render().
|
||
* console.log(state) показывает ВСЁ состояние в любой момент.
|
||
*/
|
||
function render(state) {
|
||
renderFiles(state);
|
||
renderStepper(state);
|
||
}
|
||
|
||
// syncDB, statusToHTML, renderFiles, toggleClassifyDetail, uploadFile, refreshSupps
|
||
// → вынесены в files.js (Фаза 1)
|
||
|
||
// ── Pipeline stepper (Фаза 4: state-driven) ──────────────────
|
||
|
||
/**
|
||
* renderStepper(state) — Рендер степпера из state.ui.steps (Фаза 4).
|
||
*
|
||
* Читает state.ui.steps.{upload,classify,groups,compare}, обновляет DOM.
|
||
* Значения: '○' (не начат) | '⏳' (в процессе) | '✓' (завершён).
|
||
*/
|
||
function renderStepper(state) {
|
||
var steps = state.ui.steps;
|
||
var map = { upload: 'stepUpload', classify: 'stepClassify', groups: 'stepGroups', compare: 'stepCompare' };
|
||
var colors = { '○': 'var(--muted)', '⏳': 'var(--brand-primary)', '✓': 'var(--green)' };
|
||
Object.keys(map).forEach(function(key) {
|
||
var el = document.getElementById(map[key]);
|
||
if (el) {
|
||
el.textContent = steps[key] + ' ' + {upload:'Загрузка',classify:'Классификация',groups:'Группировка',compare:'Сравнение'}[key];
|
||
el.style.color = colors[steps[key]] || 'var(--muted)';
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* stepDone(id) — Отметить шаг как завершённый (Фаза 4).
|
||
* Мутирует state.ui.steps, вызывает renderStepper.
|
||
*/
|
||
function stepDone(id) {
|
||
var key = { stepUpload: 'upload', stepClassify: 'classify', stepGroups: 'groups', stepCompare: 'compare' }[id];
|
||
if (key) { state.ui.steps[key] = '✓'; renderStepper(state); }
|
||
}
|
||
|
||
/**
|
||
* stepActive(id) — Отметить шаг как активный (Фаза 4).
|
||
* Мутирует state.ui.steps, вызывает renderStepper.
|
||
*/
|
||
function stepActive(id) {
|
||
var key = { stepUpload: 'upload', stepClassify: 'classify', stepGroups: 'groups', stepCompare: 'compare' }[id];
|
||
if (key) { state.ui.steps[key] = '⏳'; renderStepper(state); }
|
||
}
|
||
|
||
/**
|
||
* resetStepper(fromId) — Сбросить шаги начиная с fromId (Фаза 4).
|
||
* Все шаги после (и включая) fromId получают '○'.
|
||
* Мутирует state.ui.steps, вызывает renderStepper.
|
||
*/
|
||
function resetStepper(fromId) {
|
||
var order = ['stepUpload','stepClassify','stepGroups','stepCompare'];
|
||
var keys = ['upload','classify','groups','compare'];
|
||
var reset = false;
|
||
order.forEach(function(id, i) {
|
||
if (id === fromId) reset = true;
|
||
if (reset) { state.ui.steps[keys[i]] = '○'; }
|
||
});
|
||
renderStepper(state);
|
||
}
|
||
// Утилиты (formatSize, formatDate, moveUp, moveDown, escHtml, fmtDate) —
|
||
// вынесены в app_utils.js для облегчения анализа и отладки.
|
||
// statusToHTML, renderFiles → files.js (Фаза 1)
|
||
|
||
// ── Раскрыть/скрыть результат классификации под строкой файла ──
|
||
// toggleClassifyDetail → files.js (Фаза 1)
|
||
|
||
// moveUp/moveDown/removeFile/openAbout/closeAbout — вынесены в app_utils.js
|
||
|
||
// ── Автозагрузка при выборе файлов ──────────────────────────
|
||
// Фаза 1 (decoupling-final-plan.md): fileInput handler разбит на чистые функции
|
||
// onFilesSelected → reconcileSelection → addZipFile/addRegularFile → finalizeUpload
|
||
// Вся логика — в files.js. Здесь только тонкая обёртка.
|
||
fileInput.addEventListener('change', async function() {
|
||
var newFiles = Array.from(fileInput.files);
|
||
onFilesSelected(newFiles);
|
||
});
|
||
|
||
// ── Классификация ──────────────────────────────────────────
|
||
function showClassifyBtn() {
|
||
var btn = document.getElementById('classifyBtn');
|
||
if (!btn) {
|
||
btn = document.createElement('button');
|
||
btn.id = 'classifyBtn';
|
||
btn.className = 'btn btn-primary';
|
||
btn.style.cssText = 'width:100%;justify-content:center;margin-top:8px;background:var(--brand-primary);border-color:var(--brand-primary);';
|
||
btn.innerHTML = '<i data-lucide="tags" style="width:16px;height:16px;"></i> Классифицировать';
|
||
btn.addEventListener('click', runClassify);
|
||
document.getElementById('llmBtn').parentNode.insertBefore(btn, document.getElementById('llmBtn'));
|
||
}
|
||
btn.style.display = 'flex';
|
||
btn.disabled = false;
|
||
}
|
||
|
||
async function runClassify() {
|
||
var btn = document.getElementById('classifyBtn');
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="spinner"></span> Классификация... 0с';
|
||
stepActive('stepClassify');
|
||
|
||
var start = Date.now();
|
||
var timer = setInterval(function() {
|
||
btn.innerHTML = '<span class="spinner"></span> Классификация... ' + Math.round((Date.now() - start) / 1000) + 'с';
|
||
}, 1000);
|
||
|
||
var totalFiles = 0;
|
||
var classifyDone = false;
|
||
|
||
// Poll progress каждые 2с — работает и для sync, и для async classify
|
||
var pollTimer = setInterval(async function() {
|
||
try {
|
||
var r = await fetch(VM_API + '/api/batch-progress?batch=' + state.batchId);
|
||
var d = await r.json();
|
||
if (d.ok && d.counts) {
|
||
var done = (d.counts.classified || 0) + (d.counts.failed || 0);
|
||
var total = d.total || 0;
|
||
if (total > 0) totalFiles = total;
|
||
btn.innerHTML = '<span class="spinner"></span> Классификация... ' + done + '/' + total + ' (' + Math.round((Date.now() - start) / 1000) + 'с)';
|
||
if (done >= total && total > 0) {
|
||
clearInterval(pollTimer);
|
||
clearInterval(timer);
|
||
classifyDone = true;
|
||
btn.innerHTML = '✓ ' + done + '/' + total + ' классифицировано (' + Math.round((Date.now() - start) / 1000) + 'с)';
|
||
stepDone('stepClassify');
|
||
stepActive('stepGroups');
|
||
loadGroupsAction();
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
}, 2000);
|
||
|
||
try {
|
||
var resp = await fetch(VM_API + '/api/classify-batch', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({batch_id: state.batchId})
|
||
});
|
||
var data = await resp.json();
|
||
|
||
// sync classify: ответ уже содержит done
|
||
if (data.ok && data.done !== undefined) {
|
||
clearInterval(timer);
|
||
clearInterval(pollTimer);
|
||
classifyDone = true;
|
||
btn.innerHTML = '✓ ' + data.done + '/' + data.total + ' классифицировано (' + Math.round((Date.now() - start) / 1000) + 'с)';
|
||
stepDone('stepClassify');
|
||
stepActive('stepGroups');
|
||
loadGroupsAction();
|
||
} else if (data.ok) {
|
||
// async classify: 202 — ждём poll до done>=total
|
||
totalFiles = data.total || 0;
|
||
if (totalFiles === 0) {
|
||
clearInterval(timer);
|
||
clearInterval(pollTimer);
|
||
btn.innerHTML = '✗ нет файлов для классификации';
|
||
btn.disabled = false;
|
||
}
|
||
} else {
|
||
clearInterval(timer);
|
||
clearInterval(pollTimer);
|
||
btn.innerHTML = '✗ ' + (data.error || 'ошибка');
|
||
btn.disabled = false;
|
||
}
|
||
} catch(e) {
|
||
if (!classifyDone) {
|
||
clearInterval(timer);
|
||
clearInterval(pollTimer);
|
||
btn.innerHTML = '✗ ' + escHtml(e.message);
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
// loadGroups, buildGroupCard, markGroupDone → groups.js (Фаза 2)
|
||
|
||
// Текущий активный EventSource (только один одновременно)
|
||
// state._activeCompare — теперь в state.js (Фаза 0: state + render)
|
||
|
||
window.runCompareForGroup = async function(gi, btn) {
|
||
var group = state.groups[gi];
|
||
if (!group || group.contract_number === '__unresolved__') return;
|
||
|
||
// Фаза 2: отметить группу как «в процессе»
|
||
// renderGroups НЕ вызываем — инкрементальные DOM-мутации внутри SSE
|
||
group.compare = { status: 'running' };
|
||
|
||
// Остановить предыдущее сравнение
|
||
if (state._activeCompare.es) { state._activeCompare.es.close(); state._activeCompare.es = null; }
|
||
if (state._activeCompare.timer) { clearInterval(state._activeCompare.timer); state._activeCompare.timer = null; }
|
||
|
||
// Свернуть ТОЛЬКО тело текущей группы
|
||
var curBody = document.getElementById('cmpBody_' + gi);
|
||
if (curBody) curBody.style.display = 'none';
|
||
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="spinner"></span> Применяю...';
|
||
// Заблокировать ВСЕ кнопки сравнения пока идёт процесс
|
||
document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = true; });
|
||
|
||
var ar = await fetch(VM_API + '/api/apply-groups', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({batch_id: state.batchId, groups: [group]})
|
||
});
|
||
var ad = await ar.json();
|
||
if (!ad.ok || !ad.contract_ids || !ad.contract_ids.length) {
|
||
btn.innerHTML = '✗ ошибка'; btn.disabled = false;
|
||
document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; });
|
||
return;
|
||
}
|
||
|
||
var cid = ad.contract_ids[0];
|
||
|
||
// Создать контейнер для результатов сравнения
|
||
var cmpBody = document.getElementById('cmpBody_' + gi);
|
||
if (!cmpBody) {
|
||
cmpBody = document.createElement('div');
|
||
cmpBody.className = 'cmp-body';
|
||
cmpBody.id = 'cmpBody_' + gi;
|
||
cmpBody.style.cssText = 'margin-top:8px;';
|
||
btn.parentNode.insertBefore(cmpBody, btn.nextSibling);
|
||
}
|
||
cmpBody.innerHTML = '';
|
||
cmpBody.style.display = 'block';
|
||
btn.innerHTML = '<span class="spinner"></span> Сравнение...';
|
||
stepActive('stepCompare');
|
||
|
||
var statusEl = document.createElement('div');
|
||
statusEl.style.cssText = 'font-size:11px;color:var(--muted);margin-bottom:4px;';
|
||
statusEl.textContent = '⏳ 0.0с';
|
||
cmpBody.appendChild(statusEl);
|
||
|
||
// Фаза 3: унифицированный SSE через startCompareSSE (compare.js)
|
||
var result = startCompareSSE(
|
||
VM_API + '/process-v2?contract_id=' + cid,
|
||
cmpBody,
|
||
statusEl,
|
||
{
|
||
onDone: function(d, sections) {
|
||
state._activeCompare.timer = null;
|
||
state._activeCompare.es = null;
|
||
group.compare = {
|
||
status: 'done',
|
||
totalTime: d.total_time_s + 'с',
|
||
bodyHTML: cmpBody.innerHTML,
|
||
sections: sections
|
||
};
|
||
renderGroups(state);
|
||
document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; });
|
||
stepDone('stepCompare');
|
||
},
|
||
onError: function(d) {
|
||
state._activeCompare.timer = null;
|
||
state._activeCompare.es = null;
|
||
group.compare = null;
|
||
cmpBody.innerHTML += '<div style="color:var(--destructive);">✗ ' + escHtml(d.message) + '</div>';
|
||
btn.innerHTML = '✗'; btn.disabled = false;
|
||
document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; });
|
||
},
|
||
onConnectionError: function() {
|
||
state._activeCompare.timer = null;
|
||
state._activeCompare.es = null;
|
||
group.compare = null;
|
||
btn.innerHTML = '✗ соединение'; btn.disabled = false;
|
||
document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; });
|
||
}
|
||
}
|
||
);
|
||
state._activeCompare.es = result.es;
|
||
state._activeCompare.timer = result.timer;
|
||
};
|
||
|
||
// uploadFile → files.js (Фаза 1)
|
||
|
||
// ── LLM: Общее сравнение (Event Sourcing) ────────────────────────
|
||
document.getElementById('llmBtn').addEventListener('click', function() {
|
||
if (!state.contractId) return;
|
||
var btn = this;
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="spinner"></span> Сравнение...';
|
||
|
||
var compareCard = document.getElementById('compareCard');
|
||
var compareBody = document.getElementById('compareBody');
|
||
var compareStatus = document.getElementById('compareStatus');
|
||
compareCard.style.display = 'block';
|
||
compareBody.innerHTML = '';
|
||
compareStatus.textContent = '⏳ 0.0с';
|
||
|
||
var order = state.files.map(function(f) { return f.supp_id; }).filter(Boolean).join(',');
|
||
var url = 'https://check.kube5s.ru/process-v2?contract_id=' + state.contractId +
|
||
(order ? '&order=' + encodeURIComponent(order) : '');
|
||
|
||
// Фаза 3: унифицированный SSE через startCompareSSE (compare.js)
|
||
startCompareSSE(url, compareBody, compareStatus, {
|
||
onDone: function(d) {
|
||
btn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px;"></i> ✓ Готово';
|
||
stepDone('stepCompare');
|
||
if (d.total_unresolved > 0) {
|
||
compareBody.innerHTML += '<div style="margin-top:8px;color:#eab308;">⚠ ' + d.total_unresolved + ' неразрешённых строк — <a href=\"/resolve.cfm?contract_id=' + state.contractId + '\">разобрать</a></div>';
|
||
}
|
||
document.getElementById('chatCard').style.display = 'block';
|
||
lucide.createIcons();
|
||
},
|
||
onError: function(d) {
|
||
compareBody.innerHTML += '<div style="color:var(--destructive);margin-top:8px;">✗ ' + escHtml(d.message) + '</div>';
|
||
btn.innerHTML = '<i data-lucide="scale" style="width:16px;height:16px;"></i> Общее сравнение';
|
||
btn.disabled = false;
|
||
},
|
||
onConnectionError: function() {
|
||
if (compareBody.innerHTML === '') {
|
||
compareBody.innerHTML = '<div style="color:var(--destructive);">✗ Ошибка соединения</div>';
|
||
}
|
||
btn.innerHTML = '<i data-lucide="scale" style="width:16px;height:16px;"></i> Общее сравнение';
|
||
btn.disabled = false;
|
||
lucide.createIcons();
|
||
}
|
||
});
|
||
});
|
||
|
||
// refreshSupps → files.js (Фаза 1)
|
||
|
||
// ── Чат ──────────────────────────────────────────────────────
|
||
document.getElementById('chatSend').addEventListener('click', function() {
|
||
var input = document.getElementById('chatInput');
|
||
var q = input.value.trim();
|
||
if (!q || !state.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=' + state.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 = state.files[i];
|
||
var docId = f.doc_id;
|
||
document.getElementById('modalTitle').textContent = f.name;
|
||
document.getElementById('modalBody').innerHTML = '<span style="color:var(--muted);">Загрузка...</span>';
|
||
var modal = document.querySelector('.modal');
|
||
modal.classList.add('wide');
|
||
document.getElementById('modalOverlay').classList.add('open');
|
||
|
||
var leftHtml = '<span style="color:var(--muted);">Загрузка...</span>';
|
||
var rightHtml = '';
|
||
|
||
if (docId) {
|
||
try {
|
||
var qResp = await fetch(VM_API + '/api/documents/' + docId);
|
||
var qData = await qResp.json();
|
||
var elements = [];
|
||
if (qData.ok && qData.elements_json) {
|
||
var ej = qData.elements_json;
|
||
if (typeof ej === 'object' && ej.Value) ej = ej.Value;
|
||
if (typeof ej === 'string') elements = JSON.parse(ej);
|
||
else elements = ej;
|
||
}
|
||
|
||
// Общий textify для обеих панелей
|
||
var textLines = [];
|
||
elements.forEach(function(el) {
|
||
var etype = el.type || el.TYPE || '?';
|
||
if (etype === 'paragraph') {
|
||
textLines.push(el.text || el.TEXT || '');
|
||
} else if (etype === 'table') {
|
||
var rows = el.rows || el.ROWS || [];
|
||
if (rows.length > 0) {
|
||
var ncols = (rows[0]||[]).length;
|
||
textLines.push('--- Таблица ' + rows.length + '×' + ncols + ' ---');
|
||
rows.forEach(function(row) {
|
||
var cells = (row||[]).map(function(c){ return String(c||'').replace(/\n/g,' ').replace(/\|/g,'\\|'); });
|
||
textLines.push('| ' + cells.join(' | ') + ' |');
|
||
});
|
||
textLines.push('');
|
||
}
|
||
}
|
||
});
|
||
var textify = textLines.join('\n').replace(/</g,'<').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>';
|
||
if (d.error) rightHtml += '<div style="margin-top:8px;color:var(--destructive);">' + d.error + '</div>';
|
||
}
|
||
rightHtml += '<div style="margin-top:10px;font-weight:600;font-size:12px;">Все элементы:</div>';
|
||
rightHtml += '<pre>' + textify + '</pre>';
|
||
if (f.supp_id) {
|
||
rightHtml += '<div class="kv"><span class="k">Тип ДС</span><span class="v">' + (f.supp_type || '—') + '</span></div>';
|
||
}
|
||
|
||
// ── Результаты классификации ──────────────────────────
|
||
if (qData.classify_status === 'classified' || qData.classify_raw) {
|
||
rightHtml += '<div style="margin-top:12px;font-weight:600;font-size:12px;border-top:1px solid var(--brand-gray);padding-top:8px;">🏷️ Классификация LLM</div>';
|
||
if (qData.doc_type) rightHtml += '<div class="kv"><span class="k">Тип</span><span class="v">' + escHtml(qData.doc_type) + '</span></div>';
|
||
if (qData.own_number) rightHtml += '<div class="kv"><span class="k">Номер</span><span class="v">' + escHtml(qData.own_number) + '</span></div>';
|
||
if (qData.parent_number) rightHtml += '<div class="kv"><span class="k">Родительский</span><span class="v">' + escHtml(qData.parent_number) + '</span></div>';
|
||
if (qData.doc_date) rightHtml += '<div class="kv"><span class="k">Дата</span><span class="v">' + escHtml(qData.doc_date) + '</span></div>';
|
||
if (qData.counterparty) rightHtml += '<div class="kv"><span class="k">Контрагент</span><span class="v">' + escHtml(qData.counterparty) + '</span></div>';
|
||
if (qData.classify_raw) {
|
||
var rawId = 'raw_' + docId.replace(/-/g,'');
|
||
rightHtml += '<details style="margin-top:8px;"><summary style="cursor:pointer;font-size:11px;color:var(--brand-primary);">Сырой ответ LLM</summary>';
|
||
rightHtml += '<pre style="font-size:10px;max-height:200px;overflow:auto;background:var(--brand-grey-light);padding:6px;border-radius:4px;margin-top:4px;">' + escHtml(qData.classify_raw) + '</pre>';
|
||
rightHtml += '</details>';
|
||
}
|
||
}
|
||
} 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 promptRole = 'diff'; // текущая выбранная роль
|
||
var promptEditor = document.getElementById('promptEditor');
|
||
var promptStatus = document.getElementById('promptStatus');
|
||
var promptHistory = document.getElementById('promptHistory');
|
||
var promptModalOverlay = document.getElementById('promptModalOverlay');
|
||
var promptModalTitle = document.getElementById('promptModalTitle');
|
||
var promptName = document.getElementById('promptName');
|
||
var promptNotes = document.getElementById('promptNotes');
|
||
// Три роли промптов:
|
||
// extract — извлечение строк из первого документа (базовый договор)
|
||
// diff — сравнение допсоглашений (ADD/UPDATE/DELETE)
|
||
// classify — классификация документа (тип, номер, контрагент, дата)
|
||
var activePromptId = {extract: '', diff: '', classify: ''};
|
||
|
||
function openPromptModal(role) {
|
||
promptRole = role;
|
||
var labels = {extract: 'Извлечение', diff: 'Сравнение', classify: 'Классификация'};
|
||
promptModalTitle.textContent = 'Промпт: ' + (labels[role] || role);
|
||
document.getElementById('promptTabExtract').style.background = role === 'extract' ? 'var(--brand-primary)' : '';
|
||
document.getElementById('promptTabExtract').style.color = role === 'extract' ? '#fff' : '';
|
||
document.getElementById('promptTabDiff').style.background = role === 'diff' ? 'var(--brand-primary)' : '';
|
||
document.getElementById('promptTabDiff').style.color = role === 'diff' ? '#fff' : '';
|
||
document.getElementById('promptTabClassify').style.background = role === 'classify' ? 'var(--brand-primary)' : '';
|
||
document.getElementById('promptTabClassify').style.color = role === 'classify' ? '#fff' : '';
|
||
promptStatus.textContent = '';
|
||
promptName.value = '';
|
||
promptNotes.value = '';
|
||
loadPrompt(role);
|
||
promptModalOverlay.classList.add('open');
|
||
}
|
||
|
||
function closePromptModal(e) {
|
||
if (e && e.target !== promptModalOverlay) return;
|
||
promptModalOverlay.classList.remove('open');
|
||
}
|
||
|
||
function loadPrompt(role) {
|
||
fetch(VM_API + '/api/prompts?role=' + role)
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(d) {
|
||
if (d.ok) {
|
||
promptEditor.value = d.body || '';
|
||
activePromptId[role] = d.id || '';
|
||
loadHistory(role);
|
||
}
|
||
})
|
||
.catch(function(e) { console.error('loadPrompt:', e); });
|
||
}
|
||
|
||
function loadHistory(role) {
|
||
fetch(VM_API + '/api/prompts/list?role=' + role)
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(d) {
|
||
if (!d.ok || !d.versions || !d.versions.length) {
|
||
promptHistory.innerHTML = '<div style="color:var(--muted);">нет версий</div>';
|
||
return;
|
||
}
|
||
promptHistory.innerHTML = d.versions.map(function(v) {
|
||
var isActive = v.is_active ? '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--green);margin-right:4px;" title="активная"></span> ' : '';
|
||
var style = v.is_active ? 'font-weight:600;' : '';
|
||
return '<div style="display:flex;align-items:center;padding:4px 0;border-bottom:1px solid var(--brand-gray);font-size:11px;' + style + '">'
|
||
+ '<span style="flex:1;">' + isActive + escHtml(v.name) + ' <span style="color:var(--muted);">' + fmtDate(v.created_at) + '</span>'
|
||
+ (v.notes ? ' <span style="color:var(--muted);font-style:italic;">— ' + escHtml(v.notes) + '</span>' : '')
|
||
+ '</span>'
|
||
+ (!v.is_active ? '<button class="btn" onclick="activatePrompt(\'' + v.id + '\')" style="font-size:11px;height:22px;padding:0 8px;margin-left:4px;">активировать</button>' : '')
|
||
+ (!v.is_active ? '<button class="btn" onclick="deletePrompt(\'' + v.id + '\')" style="font-size:11px;height:22px;padding:0 8px;margin-left:2px;color:var(--destructive);">удалить</button>' : '')
|
||
+ '</div>';
|
||
}).join('');
|
||
})
|
||
.catch(function(e) { console.error('loadHistory:', e); });
|
||
}
|
||
|
||
function activatePrompt(id) {
|
||
fetch(VM_API + '/api/prompts/activate', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({id: id})
|
||
})
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(d) {
|
||
if (d.ok) { loadPrompt(promptRole); promptStatus.textContent = '✓ активирован'; }
|
||
else { promptStatus.textContent = '✕ ' + (d.error || 'ошибка'); }
|
||
});
|
||
}
|
||
|
||
function deletePrompt(id) {
|
||
if (!confirm('Удалить эту версию промпта?')) return;
|
||
fetch(VM_API + '/api/prompts/delete', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({id: id})
|
||
})
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(d) {
|
||
if (d.ok) { loadHistory(promptRole); promptStatus.textContent = '✓ удалено'; }
|
||
else { promptStatus.textContent = '✕ ' + (d.error || 'ошибка'); }
|
||
});
|
||
}
|
||
|
||
document.getElementById('promptSave').addEventListener('click', function() {
|
||
var body = promptEditor.value.trim();
|
||
if (!body) { promptStatus.textContent = '✕ тело промпта пустое'; return; }
|
||
var name = promptName.value.trim() || ('v' + new Date().toISOString().replace(/[:.]/g,'-').substring(0,16));
|
||
fetch(VM_API + '/api/prompts/save', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({
|
||
role: promptRole, name: name, body: body,
|
||
notes: promptNotes.value.trim(), is_active: true
|
||
})
|
||
})
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(d) {
|
||
if (d.ok) {
|
||
promptStatus.textContent = '✓ сохранено (новая версия)';
|
||
promptName.value = ''; promptNotes.value = '';
|
||
loadPrompt(promptRole);
|
||
} else {
|
||
promptStatus.textContent = '✕ ' + (d.error || 'ошибка');
|
||
}
|
||
});
|
||
});
|
||
|
||
document.getElementById('promptTabExtract').addEventListener('click', function() { openPromptModal('extract'); });
|
||
document.getElementById('promptTabDiff').addEventListener('click', function() { openPromptModal('diff'); });
|
||
document.getElementById('promptTabClassify').addEventListener('click', function() { openPromptModal('classify'); });
|
||
document.getElementById('promptBtnExtract').addEventListener('click', function() { openPromptModal('extract'); });
|
||
document.getElementById('promptBtnDiff').addEventListener('click', function() { openPromptModal('diff'); });
|
||
document.getElementById('promptBtnClassify').addEventListener('click', function() { openPromptModal('classify'); });
|
||
lucide.createIcons();
|