From 7490568b4be5259cb6efcfe5b585531f1b211b55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Thu, 25 Jun 2026 08:44:52 +0400 Subject: [PATCH] =?UTF-8?q?v1.0.178:=20=D0=A4=D0=B0=D0=B7=D0=B0=200=20?= =?UTF-8?q?=E2=80=94=20state=20+=20render(state).=20=D0=92=D0=B2=D0=B5?= =?UTF-8?q?=D0=B4=D1=91=D0=BD=20state.js=20(=D1=86=D0=B5=D0=BD=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B5=20=D1=81=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D1=8F=D0=BD=D0=B8=D0=B5),=20render(state)=20=D0=B2=D1=8B?= =?UTF-8?q?=D0=B7=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20renderTable().=20fileQue?= =?UTF-8?q?ue/contractId/batchId/=5FactiveCompare=20=E2=86=92=20state.*.?= =?UTF-8?q?=20app=5Futils.js=20=D0=B0=D0=B4=D0=B0=D0=BF=D1=82=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD.=20=D0=9F=D0=BE=D0=B4=D1=80=D0=BE?= =?UTF-8?q?=D0=B1=D0=BD=D1=8B=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=B0=D1=80=D0=B8=D0=B8=20=D0=B8=D0=B7=20decoupling-fina?= =?UTF-8?q?l-plan.md.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/app.js | 185 ++++++++++++++++++++++++-------------------- deploy/app_utils.js | 23 ++++-- deploy/state.js | 73 +++++++++++++++++ index.cfm | 7 +- 4 files changed, 193 insertions(+), 95 deletions(-) create mode 100644 deploy/state.js diff --git a/deploy/app.js b/deploy/app.js index 9d1c79d..3d5aa01 100644 --- a/deploy/app.js +++ b/deploy/app.js @@ -9,9 +9,10 @@ var SITE_URL = ''; // same origin for api calls var fileInput = document.getElementById('fileInput'); var fileTable = document.getElementById('fileTable'); -var fileQueue = []; -var contractId = null; -var batchId = crypto.randomUUID(); // классификация: привязка всех файлов сессии +// 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() { @@ -20,9 +21,26 @@ var batchId = crypto.randomUUID(); // классификация: привяз } catch(e) { /* ignore */ } })(); -// Синхронизация БД с таблицей — удалить всё, чего нет в fileQueue +/** + * 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) { + renderTable(); +} + +// Синхронизация БД с таблицей — удалить всё, чего нет в state.files async function syncDB() { - var keepIds = fileQueue.map(function(f) { return f.doc_id; }).filter(Boolean); + var keepIds = state.files.map(function(f) { return f.doc_id; }).filter(Boolean); try { await fetch(VM_API + '/api/sync', { method: 'POST', @@ -55,10 +73,10 @@ function resetStepper(fromId) { // вынесены в app_utils.js для облегчения анализа и отладки. function renderTable() { - if (fileQueue.length === 0) { + if (state.files.length === 0) { fileTable.innerHTML = 'Нет файлов — выберите .docx / .pdf'; } else { - fileTable.innerHTML = fileQueue.map(function(f, i) { + fileTable.innerHTML = state.files.map(function(f, i) { var rows = '' + '' + (f.doc_id ? '' : '') + @@ -88,7 +106,7 @@ window.toggleClassifyDetail = async function(i) { return; } - var f = fileQueue[i]; + var f = state.files[i]; if (!f || !f.doc_id) return; detailRow.style.display = ''; @@ -149,10 +167,10 @@ fileInput.addEventListener('change', async function() { // Удалить из очереди файлы, которых нет в новом выборе var newNames = new Set(newFiles.map(function(f) { return f.name; })); - fileQueue = fileQueue.filter(function(f) { + state.files = state.files.filter(function(f) { return newNames.has(f.name); }); - renderTable(); + render(state); // Разблокировать кнопку классификации — состав файлов изменился showClassifyBtn(); // Сбросить прогресс пайплайна @@ -165,9 +183,9 @@ fileInput.addEventListener('change', async function() { if (isZip) { // ZIP: показать временную строку, распаковать, убрать var zipEntry = { name: f.name, lastModified: f.lastModified, size: f.size, status: '⏳ распаковка...' }; - fileQueue.push(zipEntry); - var zipIdx = fileQueue.length - 1; - renderTable(); + state.files.push(zipEntry); + var zipIdx = state.files.length - 1; + render(state); try { var zipResp = await new Promise(function(resolve, reject) { @@ -184,8 +202,8 @@ fileInput.addEventListener('change', async function() { if (!zipData.ok || !zipData.files) throw new Error('unzip failed'); // Убрать строку ZIP из таблицы - fileQueue.splice(zipIdx, 1); - renderTable(); + state.files.splice(zipIdx, 1); + render(state); // Добавить файлы из архива, пропуская дубликаты for (var zi = 0; zi < zipData.files.length; zi++) { @@ -194,8 +212,8 @@ fileInput.addEventListener('change', async function() { // Проверить дубликат по имени var dup = -1; - for (var dj = 0; dj < fileQueue.length; dj++) { - if (fileQueue[dj].name === zf.filename) { dup = dj; break; } + for (var dj = 0; dj < state.files.length; dj++) { + if (state.files[dj].name === zf.filename) { dup = dj; break; } } if (dup >= 0) continue; // уже есть — не добавляем @@ -204,26 +222,26 @@ fileInput.addEventListener('change', async function() { doc_id: zf.doc_id, uploaded: true, status: '⏳ парсинг...' }; - fileQueue.push(zEntry); - var zIdx = fileQueue.length - 1; - if (!contractId && zf.contract_id) contractId = zf.contract_id; - renderTable(); + state.files.push(zEntry); + var zIdx = state.files.length - 1; + if (!state.contractId && zf.contract_id) state.contractId = zf.contract_id; + render(state); // Авто-парсинг: VM Python парсит ВСЁ var zpr = zf.parsed; if (zpr && zpr.status === 'parsed') { - fileQueue[zIdx].parsed = true; - fileQueue[zIdx].status = '✓ ' + zpr.element_count + ' эл.'; + state.files[zIdx].parsed = true; + state.files[zIdx].status = '✓ ' + zpr.element_count + ' эл.'; } else if (zpr && zpr.status === 'error') { - fileQueue[zIdx].status = '✗ ' + (zpr.error || 'ошибка парсинга') + ''; + state.files[zIdx].status = '✗ ' + (zpr.error || 'ошибка парсинга') + ''; } else { - fileQueue[zIdx].status = '✗ Неизвестная ошибка'; + state.files[zIdx].status = '✗ Неизвестная ошибка'; } - renderTable(); + render(state); } } catch(ze) { - fileQueue[zipIdx].status = '✗ ZIP: ' + ze.message + ''; - renderTable(); + state.files[zipIdx].status = '✗ ZIP: ' + ze.message + ''; + render(state); } continue; // ZIP обработан, переходим к следующему файлу } @@ -231,55 +249,55 @@ fileInput.addEventListener('change', async function() { // Обычный файл (не ZIP) 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; } + for (var j = 0; j < state.files.length; j++) { + if (state.files[j].name === f.name) { existingIdx = j; break; } } var rowIdx; if (existingIdx >= 0) { - fileQueue[existingIdx] = entry; + state.files[existingIdx] = entry; rowIdx = existingIdx; } else { - fileQueue.push(entry); - rowIdx = fileQueue.length - 1; + state.files.push(entry); + rowIdx = state.files.length - 1; } - renderTable(); + render(state); try { var resp = await uploadFile(f, function(pct) { - fileQueue[rowIdx].status = '↑ ' + pct + '%'; - renderTable(); + state.files[rowIdx].status = '↑ ' + pct + '%'; + render(state); }); // Python API returns lowercase keys (doc_id, contract_id, parsed) - if (resp && resp.contract_id) contractId = resp.contract_id; - fileQueue[rowIdx].doc_id = resp.doc_id; - fileQueue[rowIdx].status = ''; - fileQueue[rowIdx].uploaded = true; + if (resp && resp.contract_id) state.contractId = resp.contract_id; + state.files[rowIdx].doc_id = resp.doc_id; + state.files[rowIdx].status = ''; + state.files[rowIdx].uploaded = true; // Авто-парсинг: VM Python парсит ВСЁ (PDF + DOCX + DOC) var t0 = Date.now(); var pr = resp.parsed; if (pr && pr.status === 'parsed') { var elapsed = ((Date.now() - t0) / 1000).toFixed(1); - fileQueue[rowIdx].parsed = true; - fileQueue[rowIdx].parseInfo = pr; - fileQueue[rowIdx].status = '✓ ' + pr.element_count + ' эл. (' + elapsed + 'с)'; + state.files[rowIdx].parsed = true; + state.files[rowIdx].parseInfo = pr; + state.files[rowIdx].status = '✓ ' + pr.element_count + ' эл. (' + elapsed + 'с)'; } else if (pr && pr.status === 'error') { - fileQueue[rowIdx].status = '✗ ' + (pr.error || 'ошибка парсинга') + ''; - fileQueue[rowIdx].parseInfo = pr; + state.files[rowIdx].status = '✗ ' + (pr.error || 'ошибка парсинга') + ''; + state.files[rowIdx].parseInfo = pr; } else { - fileQueue[rowIdx].status = '✗ Неизвестная ошибка'; + state.files[rowIdx].status = '✗ Неизвестная ошибка'; } } catch(err) { - fileQueue[rowIdx].status = '✗ ' + err.message + ''; + state.files[rowIdx].status = '✗ ' + err.message + ''; } - renderTable(); + render(state); } await refreshSupps(); stepDone('stepUpload'); stepActive('stepClassify'); - // Синхронизировать БД с таблицей — удалить всё, чего нет в fileQueue + // Синхронизировать БД с таблицей — удалить всё, чего нет в state.files syncDB(); fileInput.value = ''; @@ -322,7 +340,7 @@ async function runClassify() { // Poll progress каждые 2с var pollTimer = setInterval(async function() { try { - var r = await fetch(VM_API + '/api/batch-progress?batch=' + batchId); + 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); @@ -339,7 +357,7 @@ async function runClassify() { var resp = await fetch(VM_API + '/api/classify-batch', { method: 'POST', headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({batch_id: batchId}) + body: JSON.stringify({batch_id: state.batchId}) }); var data = await resp.json(); clearInterval(timer); @@ -362,7 +380,7 @@ async function runClassify() { } async function loadGroups() { - var resp = await fetch(VM_API + '/api/groups?batch=' + batchId); + var resp = await fetch(VM_API + '/api/groups?batch=' + state.batchId); var data = await resp.json(); if (!data.ok || !data.groups) return; @@ -375,7 +393,7 @@ async function loadGroups() { stepDone('stepGroups'); // Сохраняем группы для compare - window._groupsData = data.groups; + state.groups = data.groups; data.groups.forEach(function(g, gi) { var isUnresolved = g.contract_number === '__unresolved__'; @@ -442,7 +460,7 @@ function markGroupDone(gi, time) { var bodyEl = document.getElementById('cmpBody_' + gi); var bodyHTML = bodyEl ? bodyEl.innerHTML : ''; var card = bodyEl.parentNode; - var g = window._groupsData[gi]; + var g = state.groups[gi]; card.innerHTML = '
' + '' + '📄 Договор №' + escHtml(g.contract_number || '?') + ' — ' + escHtml(g.counterparty || 'контрагент не определён') + @@ -475,16 +493,15 @@ function markGroupDone(gi, time) { } // Текущий активный EventSource (только один одновременно) -var _activeCompareES = null; -var _activeCompareTimer = null; +// state._activeCompare — теперь в state.js (Фаза 0: state + render) window.runCompareForGroup = async function(gi, btn) { - var group = window._groupsData[gi]; + var group = state.groups[gi]; if (!group || group.contract_number === '__unresolved__') return; // Остановить предыдущее сравнение - if (_activeCompareES) { _activeCompareES.close(); _activeCompareES = null; } - if (_activeCompareTimer) { clearInterval(_activeCompareTimer); _activeCompareTimer = null; } + 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); @@ -498,7 +515,7 @@ window.runCompareForGroup = async function(gi, btn) { var ar = await fetch(VM_API + '/api/apply-groups', { method: 'POST', headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({batch_id: batchId, groups: [group]}) + body: JSON.stringify({batch_id: state.batchId, groups: [group]}) }); var ad = await ar.json(); if (!ad.ok || !ad.contract_ids || !ad.contract_ids.length) { @@ -530,12 +547,12 @@ window.runCompareForGroup = async function(gi, btn) { cmpBody.appendChild(statusEl); var start = Date.now(); - _activeCompareTimer = setInterval(function() { + 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); - _activeCompareES = es; + state._activeCompare.es = es; var sections = {}; es.onmessage = function(e) { @@ -573,8 +590,8 @@ window.runCompareForGroup = async function(gi, btn) { } } else if (d.type === 'done') { - clearInterval(_activeCompareTimer); _activeCompareTimer = null; - es.close(); _activeCompareES = null; + 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)'; // Заменить карточку на обработанную (другая вёрстка, нет кнопок) @@ -584,16 +601,16 @@ window.runCompareForGroup = async function(gi, btn) { stepDone('stepCompare'); } else if (d.type === 'error') { - clearInterval(_activeCompareTimer); _activeCompareTimer = null; - es.close(); _activeCompareES = null; + 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(_activeCompareTimer); _activeCompareTimer = null; - es.close(); _activeCompareES = null; + 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; }); }; @@ -610,8 +627,8 @@ function uploadFile(file, onProgress) { var xhr = new XMLHttpRequest(); var fd = new FormData(); fd.append('files', uploadFile, uploadName); - if (contractId) fd.append('contract_id', contractId); - fd.append('batch_id', batchId); + if (state.contractId) fd.append('contract_id', state.contractId); + fd.append('batch_id', state.batchId); xhr.open('POST', UPLOAD_URL); xhr.upload.onprogress = function(e) { if (e.lengthComputable && onProgress) onProgress(Math.round(e.loaded / e.total * 100)); @@ -652,7 +669,7 @@ function uploadFile(file, onProgress) { // ── LLM: Общее сравнение (Event Sourcing) ──────────────────────── document.getElementById('llmBtn').addEventListener('click', function() { - if (!contractId) return; + if (!state.contractId) return; var btn = this; btn.disabled = true; btn.innerHTML = ' Сравнение...'; @@ -669,8 +686,8 @@ document.getElementById('llmBtn').addEventListener('click', function() { compareStatus.textContent = '⏳ ' + ((Date.now() - llmStart) / 1000).toFixed(1) + 'с'; }, 200); - var order = fileQueue.map(function(f) { return f.supp_id; }).filter(Boolean).join(','); - var es = new EventSource('https://contracts.kube5s.ru/process-v2?contract_id=' + contractId + (order ? '&order=' + encodeURIComponent(order) : '')); + var 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) { @@ -723,7 +740,7 @@ document.getElementById('llmBtn').addEventListener('click', function() { btn.innerHTML = ' ✓ Готово'; stepDone('stepCompare'); if (d.total_unresolved > 0) { - compareBody.innerHTML += '
⚠ ' + d.total_unresolved + ' неразрешённых строк — разобрать
'; + compareBody.innerHTML += '
⚠ ' + d.total_unresolved + ' неразрешённых строк — разобрать
'; } document.getElementById('chatCard').style.display = 'block'; lucide.createIcons(); @@ -751,22 +768,22 @@ document.getElementById('llmBtn').addEventListener('click', function() { // ── Обновить supplement_id/type для radio ────────────────── async function refreshSupps() { - if (!contractId) { console.log('refreshSupps: no contractId'); return; } + if (!state.contractId) { console.log('refreshSupps: no state.contractId'); return; } try { - var resp = await fetch(VM_API + '/api/supplements?contract_id=' + contractId); + var resp = await fetch(VM_API + '/api/supplements?contract_id=' + state.contractId); var data = await resp.json(); console.log('refreshSupps: data=', data); if (data.ok && data.supplements) { data.supplements.forEach(function(supp) { 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; + for (var i = 0; i < state.files.length; i++) { + if (state.files[i].doc_id === supp.document_id) { + state.files[i].supp_id = supp.id; + state.files[i].supp_type = supp.type; } } }); - renderTable(); + render(state); } } catch(e) { console.log('refreshSupps error:', e); } } @@ -775,7 +792,7 @@ async function refreshSupps() { document.getElementById('chatSend').addEventListener('click', function() { var input = document.getElementById('chatInput'); var q = input.value.trim(); - if (!q || !contractId) return; + if (!q || !state.contractId) return; var msgs = document.getElementById('chatMessages'); msgs.innerHTML += '
Вы: ' + q + '
'; input.value = ''; @@ -785,7 +802,7 @@ document.getElementById('chatSend').addEventListener('click', function() { var fd = new FormData(); fd.append('question', q); - fetch('/chat.cfm?contract_id=' + contractId, { method: 'POST', body: fd }) + fetch('/chat.cfm?contract_id=' + state.contractId, { method: 'POST', body: fd }) .then(function(r) { return r.json(); }) .then(function(d) { msgs.lastChild.remove(); @@ -811,7 +828,7 @@ function closeModal(e) { } async function showText(i) { - var f = fileQueue[i]; + var f = state.files[i]; var docId = f.doc_id; document.getElementById('modalTitle').textContent = f.name; document.getElementById('modalBody').innerHTML = 'Загрузка...'; diff --git a/deploy/app_utils.js b/deploy/app_utils.js index 32879fd..d65a8a5 100644 --- a/deploy/app_utils.js +++ b/deploy/app_utils.js @@ -28,11 +28,14 @@ function formatDate(ts) { /** * Удаляет файл из очереди по индексу. * Вызывается по кнопке ✕ в таблице файлов. + * + * Фаза 0 (state + render): мутирует state.files, затем render(state). + * Если очередь опустела — сбрасывает state.contractId. */ function removeFile(i) { - fileQueue.splice(i, 1); - if (fileQueue.length === 0) contractId = null; - renderTable(); + state.files.splice(i, 1); + if (state.files.length === 0) state.contractId = null; + render(state); // Синхронизировать БД с таблицей if (typeof syncDB === 'function') syncDB(); // Сбросить прогресс при изменении состава файлов @@ -45,21 +48,25 @@ window.removeFile = removeFile; /** * Переместить файл на одну позицию ВВЕРХ (сохранено для возможного возврата ручной сортировки). * В текущей версии (v1.0.164+) не используется — порядок определяет авто-классификация. + * + * Фаза 0 (state + render): мутирует state.files, затем render(state). */ function moveUp(i) { if (i <= 0) return; - var tmp = fileQueue[i]; fileQueue[i] = fileQueue[i-1]; fileQueue[i-1] = tmp; - renderTable(); + var tmp = state.files[i]; state.files[i] = state.files[i-1]; state.files[i-1] = tmp; + render(state); } /** * Переместить файл на одну позицию ВНИЗ (сохранено для возможного возврата ручной сортировки). * В текущей версии (v1.0.164+) не используется — порядок определяет авто-классификация. + * + * Фаза 0 (state + render): мутирует state.files, затем render(state). */ function moveDown(i) { - if (i >= fileQueue.length - 1) return; - var tmp = fileQueue[i]; fileQueue[i] = fileQueue[i+1]; fileQueue[i+1] = tmp; - renderTable(); + if (i >= state.files.length - 1) return; + var tmp = state.files[i]; state.files[i] = state.files[i+1]; state.files[i+1] = tmp; + render(state); } window.moveUp = moveUp; window.moveDown = moveDown; diff --git a/deploy/state.js b/deploy/state.js new file mode 100644 index 0000000..4905df5 --- /dev/null +++ b/deploy/state.js @@ -0,0 +1,73 @@ +/** + * state.js — Центральное состояние приложения. + * + * ПАТТЕРН (из decoupling-final-plan.md, Фаза 0): + * action → мутация state → render(state) + * + * ПРАВИЛА: + * 1. ВСЕ состояния приложения — только здесь, в объекте state. + * 2. DOM — проекция state, никто не читает данные из DOM. + * Если что-то нужно узнать — смотри в state, а не в element.innerHTML. + * 3. Мутировал любое поле state → обязан вызвать render(state). + * Никаких прямых манипуляций DOM в обход render(). + * 4. В любой момент console.log(state) показывает ВСЁ состояние приложения. + * + * СТРУКТУРА (Фаза 0 — минимальная, будет расширяться в Фазах 1-4): + * + * state.files — бывший fileQueue, массив загруженных файлов. + * Каждый элемент: { name, size, lastModified, file, doc_id, + * uploaded, parsed, parseInfo, supp_id, supp_type, status } + * + * state.contractId — бывший contractId, ID контракта в БД. + * Приходит с бэкенда после первого успешного upload. + * null пока не загружен ни один файл. + * + * state.batchId — бывший batchId, уникальный ID сессии (UUID). + * Используется для привязки всех файлов сессии при классификации. + * Генерируется один раз при загрузке страницы. + * + * state.groups — бывший window._groupsData. + * Массив групп после классификации. Каждая группа: + * { contract_number, counterparty, documents[], unresolved? } + * null если классификация ещё не запускалась. + * + * state._activeCompare — бывшие _activeCompareES и _activeCompareTimer. + * Управляет ОДНИМ активным SSE-сравнением. + * Правило: только одно сравнение одновременно — все кнопки блокируются. + * { es: EventSource|null, timer: intervalId|null } + * + * state.ui — UI-состояние, не связанное с данными. + * ui.steps: { upload, classify, groups, compare } + * Каждый шаг: '○' (не начат) | '⏳' (в процессе) | '✓' (завершён) + * В Фазе 4 будет renderStepper(state). + */ +var state = { + // ── Файлы (бывший fileQueue) ────────────────────────────── + files: [], + + // ── Контракт (бывший contractId) ────────────────────────── + contractId: null, + + // ── Сессия (бывший batchId) ─────────────────────────────── + // crypto.randomUUID() — браузерный API, поддержка 92%+ (Chrome 92+, FF 95+, Safari 15.4+) + batchId: crypto.randomUUID(), + + // ── Группы (бывший window._groupsData) ──────────────────── + groups: null, + + // ── Активное сравнение ──────────────────────────────────── + _activeCompare: { + es: null, // EventSource (SSE-соединение с /process-v2) + timer: null // setInterval ID для индикатора времени + }, + + // ── UI-состояние ────────────────────────────────────────── + ui: { + steps: { + upload: '○', + classify: '○', + groups: '○', + compare: '○' + } + } +}; diff --git a/index.cfm b/index.cfm index 118cfc5..a099003 100644 --- a/index.cfm +++ b/index.cfm @@ -75,7 +75,7 @@
Nubes - Сверка договоров — LLM AI-driven Event Sourcing v1.0.177 + Сверка договоров — LLM AI-driven Event Sourcing v1.0.178
○ Загрузка ○ Классификация @@ -245,7 +245,8 @@
- - + + +