Files
contracts-flask/deploy-lucee/app.js
T

624 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔
// 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';
// 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 = '✗ ' + 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);">✗ ' + 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://contracts.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;">✗ ' + 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,'&lt;').replace(/>/g,'&gt;');
// Левая: сырой текст (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();