// Contracts App — весь JS на VM (contracts.kube5s.ru) // Lucee: только домен + index.cfm-скелет var VM_API = 'https://contracts.kube5s.ru'; var UPLOAD_URL = VM_API + '/upload'; var CONVERT_URL = VM_API + '/convert-doc'; var UNZIP_URL = VM_API + '/unzip-upload'; var UNZIP_URL = 'https://contracts.kube5s.ru/unzip-upload'; var SITE_URL = ''; // same origin for api calls 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); } // syncDB, statusToHTML, renderFiles, toggleClassifyDetail, uploadFile, refreshSupps // → вынесены в files.js (Фаза 1) // ── Pipeline stepper ───────────────────────────────────────── function stepDone(id) { var el = document.getElementById(id); if (el) { el.innerHTML = el.innerHTML.replace('○','✓'); el.style.color = 'var(--green)'; } } function stepActive(id) { var el = document.getElementById(id); if (el) { el.innerHTML = el.innerHTML.replace('○','⏳').replace('✓','⏳'); el.style.color = 'var(--brand-primary)'; } } function resetStepper(fromId) { var steps = ['stepUpload','stepClassify','stepGroups','stepCompare']; var reset = false; steps.forEach(function(id) { if (id === fromId) reset = true; if (reset) { var el = document.getElementById(id); if (el) { el.innerHTML = el.innerHTML.replace('✓','○').replace('⏳','○'); el.style.color = 'var(--muted)'; } } }); } // Утилиты (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 = ' Классифицировать'; 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 = ' Классификация... 0с'; stepActive('stepClassify'); var start = Date.now(); var timer = setInterval(function() { btn.innerHTML = ' Классификация... ' + Math.round((Date.now() - start) / 1000) + 'с'; }, 1000); // Poll progress каждые 2с 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; btn.innerHTML = ' Классификация... ' + done + '/' + total + ' (' + Math.round((Date.now() - start) / 1000) + 'с)'; if (done >= total && total > 0) { clearInterval(pollTimer); } } } 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(); clearInterval(timer); clearInterval(pollTimer); if (data.ok) { btn.innerHTML = '✓ ' + data.done + '/' + data.total + ' классифицировано (' + Math.round((Date.now() - start) / 1000) + 'с)'; stepDone('stepClassify'); stepActive('stepGroups'); loadGroups(); } else { btn.innerHTML = '✗ ' + (data.error || 'ошибка'); btn.disabled = false; } } catch(e) { clearInterval(timer); clearInterval(pollTimer); btn.innerHTML = '✗ ' + e.message; btn.disabled = false; } } async function loadGroups() { var resp = await fetch(VM_API + '/api/groups?batch=' + state.batchId); var data = await resp.json(); if (!data.ok || !data.groups) return; var diffBody = document.getElementById('diffBody'); var diffCard = document.getElementById('diffCard'); diffCard.style.display = 'block'; var realGroups = data.groups.filter(function(g) { return g.contract_number !== '__unresolved__'; }); var unresolvedGroup = data.groups.find(function(g) { return g.contract_number === '__unresolved__'; }); diffBody.innerHTML = '
📋 Найдено групп: ' + realGroups.length + (unresolvedGroup ? ' | ⚠ ' + unresolvedGroup.documents.length + ' файлов не распознано' : '') + '
'; stepDone('stepGroups'); // Сохраняем группы для compare state.groups = data.groups; data.groups.forEach(function(g, gi) { var isUnresolved = g.contract_number === '__unresolved__'; var card = document.createElement('div'); card.style.cssText = 'border:1px solid ' + (isUnresolved ? 'var(--destructive)' : 'var(--brand-gray)') + ';border-radius:6px;padding:10px;margin-bottom:8px;'; if (isUnresolved) { var docsHtml = g.documents.map(function(d) { var reason = ''; if (d.parent_number) { reason = ' — нет базового договора №' + escHtml(d.parent_number); } else if (d.classify_status === 'failed') { reason = ' — ошибка классификации: ' + escHtml(d.error_message || '?'); } else if (!d.doc_type || d.doc_type === 'other') { reason = ' — тип не определён'; } else { reason = ' — нет matching-договора'; } return '
' + '[' + escHtml(d.doc_type || '?') + '] ' + escHtml(d.filename) + (d.doc_date ? ' ' + escHtml(d.doc_date) + '' : '') + '' + reason + '' + '
'; }).join(''); card.innerHTML = '
❓ Не распознано (' + g.documents.length + ' файлов)
' + '
' + docsHtml + '
' + '
Загрузите недостающие базовые договоры для этих номеров.
'; } else { buildGroupCard(g, gi, card); } diffBody.appendChild(card); }); // Один обработчик на все кнопки сравнения diffBody.querySelectorAll('.cmp-btn').forEach(function(btn) { btn.addEventListener('click', function() { var gi = parseInt(this.getAttribute('data-gi')); runCompareForGroup(gi, this); }); }); lucide.createIcons(); } // ── Карточка необработанной группы ────────────────────────── function buildGroupCard(g, gi, card) { card.innerHTML = '
' + '📄 Договор №' + escHtml(g.contract_number || '?') + ' — ' + escHtml(g.counterparty || 'контрагент не определён') + '
' + '
' + g.documents.map(function(d) { var typeLabel = {contract: 'договор', supplement: 'допсоглашение', specification: 'спецификация'}[d.doc_type] || d.doc_type; return '
' + '' + escHtml(typeLabel || '?') + ' ' + escHtml(d.filename) + (d.doc_date ? ' ' + escHtml(d.doc_date) + '' : '') + '
'; }).join('') + '
' + ''; // Необработанная: только кнопка, ничего больше } // ── Заменить карточку на обработанную ─────────────────────── function markGroupDone(gi, time) { var bodyEl = document.getElementById('cmpBody_' + gi); var bodyHTML = bodyEl ? bodyEl.innerHTML : ''; var card = bodyEl.parentNode; var g = state.groups[gi]; card.innerHTML = '
' + '' + '📄 Договор №' + escHtml(g.contract_number || '?') + ' — ' + escHtml(g.counterparty || 'контрагент не определён') + ' ✓ Готово (' + time + ')' + '
' + '
' + g.documents.map(function(d, di) { var typeLabel = {contract: 'договор', supplement: 'допсоглашение', specification: 'спецификация'}[d.doc_type] || d.doc_type; return '
' + '' + escHtml(typeLabel || '?') + ' ' + escHtml(d.filename) + (d.doc_date ? ' ' + escHtml(d.doc_date) + '' : '') + '
'; }).join('') + '
' + '
' + bodyHTML + '
'; // Клик на заголовок → свернуть/развернуть card.querySelector('.cmp-header').addEventListener('click', function() { var body = document.getElementById('cmpBody_' + gi); var arrow = document.getElementById('cmpArrow_' + gi); if (body) { if (body.style.display === 'none') { body.style.display = 'block'; if (arrow) arrow.textContent = '▾'; } else { body.style.display = 'none'; if (arrow) arrow.textContent = '▸'; } } }); } // Текущий активный 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; // Остановить предыдущее сравнение 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 = ' Применяю...'; // Заблокировать ВСЕ кнопки сравнения пока идёт процесс 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 = ''; var cmpBody = document.getElementById('cmpBody_' + gi); cmpBody.style.display = 'block'; cmpBody.innerHTML = ''; btn.innerHTML = ' Сравнение...'; 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); var start = Date.now(); state._activeCompare.timer = setInterval(function() { statusEl.textContent = '⏳ ' + ((Date.now() - start) / 1000).toFixed(1) + 'с'; }, 200); var es = new EventSource(VM_API + '/process-v2?contract_id=' + cid); state._activeCompare.es = es; var sections = {}; 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 = '
⏳ ' + d.filename + '
'; cmpBody.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 = '
+' + (s.added||0) + ' ~' + (s.updated||0) + ' -' + (s.deleted||0) + '
'; html += '
'; d.ops.forEach(function(op) { var cls = op.action === 'ADD' ? 'diff-added' : op.action === 'DELETE' ? 'diff-deleted' : 'diff-changed'; var nr = op.new_row || {}; html += ''; }); html += '
ДействиеУслугаЦенаКол-воСуммаДата
' + op.action + '' + (nr.name||'') + '' + (nr.price!=null?nr.price:'') + '' + (nr.qty!=null?nr.qty:'') + '' + (nr.sum!=null?nr.sum:'') + '' + (nr.date_start||'') + '
'; body.innerHTML = html; body.style.display = 'block'; } } else if (d.type === 'done') { clearInterval(state._activeCompare.timer); state._activeCompare.timer = null; es.close(); state._activeCompare.es = null; statusEl.textContent = '✓ ' + d.total_time_s + 'с'; statusEl.style.color = 'var(--green)'; // Заменить карточку на обработанную (другая вёрстка, нет кнопок) markGroupDone(gi, d.total_time_s + 'с'); // Разблокировать остальные кнопки document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; }); stepDone('stepCompare'); } else if (d.type === 'error') { clearInterval(state._activeCompare.timer); state._activeCompare.timer = null; es.close(); state._activeCompare.es = null; cmpBody.innerHTML += '
✗ ' + d.message + '
'; btn.innerHTML = '✗'; btn.disabled = false; document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; }); } }; es.onerror = function() { clearInterval(state._activeCompare.timer); state._activeCompare.timer = null; es.close(); state._activeCompare.es = null; btn.innerHTML = '✗ соединение'; btn.disabled = false; document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; }); }; }; // 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 = ' Сравнение...'; 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 llmStart = Date.now(); var timerInterval = setInterval(function() { compareStatus.textContent = '⏳ ' + ((Date.now() - llmStart) / 1000).toFixed(1) + 'с'; }, 200); var order = state.files.map(function(f) { return f.supp_id; }).filter(Boolean).join(','); var es = new EventSource('https://contracts.kube5s.ru/process-v2?contract_id=' + state.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 = '
⏳ ' + d.filename + '
'; compareBody.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 = '
+' + (s.added||0) + ' ~' + (s.updated||0) + ' -' + (s.deleted||0) + ' ?' + (s.unresolved||0) + '
'; html += '
'; 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 += ''; }); html += '
ДействиеУслугаЦенаКол-воСуммаДата
' + action + '' + name + '' + price + '' + qty + '' + sum + '' + ds + '
'; body.innerHTML = html; body.style.display = 'block'; } } else if (d.type === 'extract_error' || d.type === 'apply_error') { compareBody.innerHTML += '
✗ ' + d.filename + ': ' + d.error + '
'; } else if (d.type === 'done') { clearInterval(timerInterval); es.close(); compareStatus.textContent = '✓ ' + d.total_time_s + 'с'; btn.innerHTML = ' ✓ Готово'; stepDone('stepCompare'); if (d.total_unresolved > 0) { compareBody.innerHTML += '
⚠ ' + d.total_unresolved + ' неразрешённых строк — разобрать
'; } document.getElementById('chatCard').style.display = 'block'; lucide.createIcons(); } else if (d.type === 'error') { clearInterval(timerInterval); es.close(); compareBody.innerHTML += '
✗ ' + d.message + '
'; btn.innerHTML = ' Общее сравнение'; btn.disabled = false; } }; es.onerror = function() { clearInterval(timerInterval); es.close(); if (compareBody.innerHTML === '') { compareBody.innerHTML = '
✗ Ошибка соединения
'; } btn.innerHTML = ' Общее сравнение'; 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 += '
Вы: ' + q + '
'; input.value = ''; input.disabled = true; document.getElementById('chatSend').disabled = true; msgs.innerHTML += '
⏳ Думаю...
'; 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 += '
Ответ: ' + (d.OK ? d.ANSWER : (d.ERROR || '—')) + '
'; input.disabled = false; document.getElementById('chatSend').disabled = false; input.focus(); }).catch(function() { msgs.lastChild.remove(); msgs.innerHTML += '
Ошибка сети
'; 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 = 'Загрузка...'; var modal = document.querySelector('.modal'); modal.classList.add('wide'); document.getElementById('modalOverlay').classList.add('open'); var leftHtml = 'Загрузка...'; 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,'>'); // Левая: сырой текст (textify) leftHtml = '
' + textify + '
'; // Правая: статистика + textify rightHtml += '
Размер' + formatSize(f.size) + '
'; rightHtml += '
Изменён' + formatDate(f.lastModified) + '
'; if (f.parseInfo) { var d = f.parseInfo; rightHtml += '
Элементов' + (d.element_count||0) + '
'; if (d.error) rightHtml += '
' + d.error + '
'; } rightHtml += '
Все элементы:
'; rightHtml += '
' + textify + '
'; if (f.supp_id) { rightHtml += '
Тип ДС' + (f.supp_type || '—') + '
'; } // ── Результаты классификации ────────────────────────── if (qData.classify_status === 'classified' || qData.classify_raw) { rightHtml += '
🏷️ Классификация LLM
'; if (qData.doc_type) rightHtml += '
Тип' + escHtml(qData.doc_type) + '
'; if (qData.own_number) rightHtml += '
Номер' + escHtml(qData.own_number) + '
'; if (qData.parent_number) rightHtml += '
Родительский' + escHtml(qData.parent_number) + '
'; if (qData.doc_date) rightHtml += '
Дата' + escHtml(qData.doc_date) + '
'; if (qData.counterparty) rightHtml += '
Контрагент' + escHtml(qData.counterparty) + '
'; if (qData.classify_raw) { var rawId = 'raw_' + docId.replace(/-/g,''); rightHtml += '
Сырой ответ LLM'; rightHtml += '
' + escHtml(qData.classify_raw) + '
'; rightHtml += '
'; } } } catch(e) { leftHtml = 'Ошибка'; } } else { leftHtml = '(ещё не загружен)'; } var html = '
' + '
Сырой текст
' + leftHtml + '
' + '
Распарсено
' + rightHtml + '
' + '
'; 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'); // Три роли промптов: // 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 = '
нет версий
'; return; } promptHistory.innerHTML = d.versions.map(function(v) { var isActive = v.is_active ? ' ' : ''; var style = v.is_active ? 'font-weight:600;' : ''; return '
' + '' + isActive + escHtml(v.name) + ' ' + fmtDate(v.created_at) + '' + (v.notes ? ' — ' + escHtml(v.notes) + '' : '') + '' + (!v.is_active ? '' : '') + (!v.is_active ? '' : '') + '
'; }).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();