// 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'); var fileQueue = []; var contractId = null; var batchId = crypto.randomUUID(); // классификация: привязка всех файлов сессии // Автоочистка старых записей без batch_id при загрузке страницы (async function cleanup() { try { await fetch(VM_API + '/api/cleanup?batch=' + batchId, { method: 'POST' }); } catch(e) { /* ignore */ } })(); // Утилиты (formatSize, formatDate, moveUp, moveDown, escHtml, fmtDate) — // вынесены в app_utils.js для облегчения анализа и отладки. function renderTable() { if (fileQueue.length === 0) { fileTable.innerHTML = 'Нет файлов — выберите .docx / .pdf'; } else { fileTable.innerHTML = fileQueue.map(function(f, i) { return '' + '' + f.name + '' + '' + formatDate(f.lastModified) + '' + '' + formatSize(f.size) + '' + '' + (f.status || '') + '' + '' + '' + ''; }).join(''); } lucide.createIcons(); } // moveUp/moveDown/removeFile/openAbout/closeAbout — вынесены в app_utils.js // ── Автозагрузка при выборе файлов ────────────────────────── fileInput.addEventListener('change', async function() { var newFiles = Array.from(fileInput.files); if (newFiles.length === 0) return; fileInput.disabled = true; for (var i = 0; i < newFiles.length; i++) { var f = newFiles[i]; var isZip = f.name.toLowerCase().endsWith('.zip'); if (isZip) { // ZIP: показать временную строку, распаковать, убрать var zipEntry = { name: f.name, lastModified: f.lastModified, size: f.size, status: '⏳ распаковка...' }; fileQueue.push(zipEntry); var zipIdx = fileQueue.length - 1; renderTable(); try { var zipResp = await new Promise(function(resolve, reject) { var xhr = new XMLHttpRequest(); xhr.open('POST', UNZIP_URL); xhr.responseType = 'json'; xhr.onload = function() { resolve(xhr.response); }; xhr.onerror = function() { reject(new Error('Сеть')); }; xhr.ontimeout = function() { reject(new Error('Таймаут')); }; xhr.timeout = 60000; xhr.send(f); }); var zipData = zipResp; if (!zipData.ok || !zipData.files) throw new Error('unzip failed'); // Убрать строку ZIP из таблицы fileQueue.splice(zipIdx, 1); renderTable(); // Добавить файлы из архива, пропуская дубликаты for (var zi = 0; zi < zipData.files.length; zi++) { var zf = zipData.files[zi]; if (zf.error) continue; // пропускаем ошибки // Проверить дубликат по имени var dup = -1; for (var dj = 0; dj < fileQueue.length; dj++) { if (fileQueue[dj].name === zf.filename) { dup = dj; break; } } if (dup >= 0) continue; // уже есть — не добавляем var zEntry = { name: zf.filename, size: zf.size, 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(); // Авто-парсинг: VM Python парсит ВСЁ var zpr = zf.parsed; if (zpr && zpr.status === 'parsed') { fileQueue[zIdx].parsed = true; fileQueue[zIdx].status = '✓ ' + zpr.element_count + ' эл.'; } else if (zpr && zpr.status === 'error') { fileQueue[zIdx].status = '✗ ' + (zpr.error || 'ошибка парсинга') + ''; } else { fileQueue[zIdx].status = '✗ Неизвестная ошибка'; } renderTable(); } } catch(ze) { fileQueue[zipIdx].status = '✗ ZIP: ' + ze.message + ''; renderTable(); } continue; // ZIP обработан, переходим к следующему файлу } // Обычный файл (не 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; } } var rowIdx; if (existingIdx >= 0) { fileQueue[existingIdx] = entry; rowIdx = existingIdx; } else { fileQueue.push(entry); rowIdx = fileQueue.length - 1; } renderTable(); try { var resp = await uploadFile(f, function(pct) { fileQueue[rowIdx].status = '↑ ' + pct + '%'; renderTable(); }); // 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; // Авто-парсинг: 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 + 'с)'; } else if (pr && pr.status === 'error') { fileQueue[rowIdx].status = '✗ ' + (pr.error || 'ошибка парсинга') + ''; fileQueue[rowIdx].parseInfo = pr; } else { fileQueue[rowIdx].status = '✗ Неизвестная ошибка'; } } catch(err) { fileQueue[rowIdx].status = '✗ ' + err.message + ''; } renderTable(); } await refreshSupps(); fileInput.value = ''; fileInput.disabled = false; // Показать кнопки после авто-парсинга var llmBtn = document.getElementById('llmBtn'); llmBtn.style.display = 'flex'; llmBtn.disabled = false; showClassifyBtn(); lucide.createIcons(); }); // ── Классификация ────────────────────────────────────────── 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 = ' Классификация...'; try { var resp = await fetch(VM_API + '/api/classify-batch', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({batch_id: batchId}) }); var data = await resp.json(); if (data.ok) { btn.innerHTML = '✓ ' + data.done + '/' + data.total + ' классифицировано'; loadGroups(); } else { btn.innerHTML = '✗ ' + (data.error || 'ошибка'); btn.disabled = false; } } catch(e) { btn.innerHTML = '✗ ' + e.message; btn.disabled = false; } } async function loadGroups() { var resp = await fetch(VM_API + '/api/groups?batch=' + 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'; diffBody.innerHTML = '
📋 Найдено групп: ' + data.groups.length + '
'; // Сохраняем группы для compare window._groupsData = 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 = ' — нет базового договора №' + d.parent_number; } else if (d.classify_status === 'failed') { reason = ' — ошибка классификации: ' + (d.error_message || '?'); } else if (!d.doc_type || d.doc_type === 'other') { reason = ' — тип не определён'; } else { reason = ' — нет matching-договора'; } return '
' + '[' + (d.doc_type || '?') + '] ' + d.filename + (d.doc_date ? ' ' + d.doc_date + '' : '') + '' + reason + '' + '
'; }).join(''); card.innerHTML = '
❓ Не распознано (' + g.documents.length + ' файлов)
' + '
' + docsHtml + '
' + '
Добавьте базовые договоры для этих номеров или перетащите файлы в группу вручную.
'; } else { card.innerHTML = '
📄 Договор №' + (g.contract_number || '?') + ' — ' + (g.counterparty || 'контрагент не определён') + '
' + '
' + g.documents.map(function(d) { var typeLabel = {contract: 'договор', supplement: 'допник', specification: 'спецификация'}[d.doc_type] || d.doc_type; return '
' + '' + (typeLabel || '?') + ' ' + d.filename + (d.doc_date ? ' ' + d.doc_date + '' : '') + '
'; }).join('') + '
'; card.innerHTML += ''; } diffBody.appendChild(card); }); lucide.createIcons(); } window.runCompareForGroup = async function(gi) { var group = window._groupsData[gi]; if (!group || group.contract_number === '__unresolved__') return; var btn = event.target; btn.disabled = true; btn.innerHTML = ' Применяю группу...'; // 1. Apply groups (creates contracts + supplements) var ar = await fetch(VM_API + '/api/apply-groups', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({batch_id: batchId, groups: [group]}) }); var ad = await ar.json(); if (!ad.ok) { btn.innerHTML = '✗ ' + ad.error; btn.disabled = false; return; } // 2. Get the created contract_id from supplements var sr = await fetch(VM_API + '/api/supplements?contract_id=&batch=' + batchId); // Actually need contract_id — let's get it from groups data btn.innerHTML = ' Сравнение...'; // Use EventSource for process-v2 — but we need contract_id // For now: use the old compare flow with contractId btn.innerHTML = '✓ Готово — обновите страницу и нажмите «Общее сравнение»'; btn.disabled = false; }; function uploadFile(file, onProgress) { return new Promise(function(resolve, reject) { // .doc → конвертация в docx var isDoc = file.name.toLowerCase().endsWith('.doc') && !file.name.toLowerCase().endsWith('.docx'); var uploadFile = file; var uploadName = file.name; function doUpload() { var xhr = new XMLHttpRequest(); var fd = new FormData(); fd.append('files', uploadFile, uploadName); if (contractId) fd.append('contract_id', contractId); fd.append('batch_id', batchId); xhr.open('POST', UPLOAD_URL); xhr.upload.onprogress = function(e) { if (e.lengthComputable && onProgress) onProgress(Math.round(e.loaded / e.total * 100)); }; xhr.onload = function() { try { var r = JSON.parse(xhr.responseText); if (r.ok) resolve(r); else reject(new Error(r.error || 'Неизвестная ошибка')); } catch(e) { reject(new Error('Некорректный ответ')); } }; xhr.onerror = function() { reject(new Error('Сеть')); }; xhr.ontimeout = function() { reject(new Error('Таймаут')); }; xhr.timeout = 180000; xhr.send(fd); } if (isDoc) { var xhr = new XMLHttpRequest(); xhr.open('POST', CONVERT_URL); xhr.responseType = 'blob'; xhr.onload = function() { if (xhr.status === 200 && xhr.response.size > 100) { uploadFile = xhr.response; uploadName = file.name.replace(/\.doc$/i, '.docx'); doUpload(); } else { reject(new Error('Конвертация .doc')); } }; xhr.onerror = function() { reject(new Error('Конвертер')); }; xhr.send(file); } else { doUpload(); } }); } // ── LLM: Общее сравнение (Event Sourcing) ──────────────────────── document.getElementById('llmBtn').addEventListener('click', function() { if (!contractId) return; var btn = this; btn.disabled = true; btn.innerHTML = ' Сравнение...'; var diffCard = document.getElementById('diffCard'); var diffBody = document.getElementById('diffBody'); var diffStatus = document.getElementById('diffStatus'); diffCard.style.display = 'block'; diffBody.innerHTML = ''; diffStatus.textContent = '⏳ 0.0с'; var llmStart = Date.now(); var timerInterval = setInterval(function() { diffStatus.textContent = '⏳ ' + ((Date.now() - llmStart) / 1000).toFixed(1) + 'с'; }, 200); var 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 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 + '
'; sec.querySelector('.diff-section-header').addEventListener('click', function() { var body = sec.querySelector('.diff-section-body'); body.style.display = body.style.display === 'none' ? 'block' : 'none'; }); diffBody.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') { diffBody.innerHTML += '
✗ ' + d.filename + ': ' + d.error + '
'; } else if (d.type === 'done') { clearInterval(timerInterval); es.close(); diffStatus.textContent = '✓ ' + d.total_time_s + 'с'; btn.innerHTML = ' ✓ Готово'; if (d.total_unresolved > 0) { diffBody.innerHTML += '
⚠ ' + d.total_unresolved + ' неразрешённых строк — разобрать
'; } document.getElementById('chatCard').style.display = 'block'; lucide.createIcons(); } else if (d.type === 'error') { clearInterval(timerInterval); es.close(); diffBody.innerHTML += '
✗ ' + d.message + '
'; btn.innerHTML = ' Общее сравнение'; btn.disabled = false; } }; es.onerror = function() { clearInterval(timerInterval); es.close(); if (diffBody.innerHTML === '') { diffBody.innerHTML = '
✗ Ошибка соединения
'; } btn.innerHTML = ' Общее сравнение'; btn.disabled = false; lucide.createIcons(); }; }); // ── Обновить supplement_id/type для radio ────────────────── async function refreshSupps() { if (!contractId) { console.log('refreshSupps: no contractId'); return; } try { var resp = await fetch(VM_API + '/api/supplements?contract_id=' + 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; } } }); renderTable(); } } catch(e) { console.log('refreshSupps error:', e); } } // ── Чат ────────────────────────────────────────────────────── 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 += '
Вы: ' + 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=' + 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 = fileQueue[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 || '—') + '
'; } } 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'); var activePromptId = {extract: '', diff: ''}; function openPromptModal(role) { promptRole = role; promptModalTitle.textContent = 'Промпт: ' + (role === 'extract' ? 'Извлечение' : 'Сравнение'); 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' : ''; 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.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('promptBtnExtract').addEventListener('click', function() { openPromptModal('extract'); }); document.getElementById('promptBtnDiff').addEventListener('click', function() { openPromptModal('diff'); }); lucide.createIcons();