processing: clarify per-file stages and outcomes

Expose extraction, analysis, and replacement progress in the file table; preserve row mapping for duplicates and collapsed archives.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Repinoid
2026-10-24 11:54:09 +03:00
co-authored by Copilot
parent f476d6cd14
commit 1b0bcd298a
8 changed files with 359 additions and 138 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ if _sys_path_root not in sys.path:
sys.path.insert(0, _sys_path_root)
# Версия приложения (меняется при изменениях)
VERSION = "1.1.4"
VERSION = "1.1.5"
def setup_logging():
+5 -5
View File
@@ -150,8 +150,8 @@ def process_stream(sid):
eta = {"total_chars": 0, "done_chars": 0, "cur_chars": 0, "cur_total": 0, "cur_done": 0}
_TOKENS_PER_CHAR = 8.0 # эмпирический коэффициент символов -> токенов LLM
def progress(phase, idx, total_, name, elapsed):
q.put(("progress", phase, idx, name, total_, elapsed))
def progress(phase, idx, total_, name, elapsed, source_idxs):
q.put(("progress", phase, idx, name, total_, elapsed, source_idxs))
# Состояние для per-file ETA (чанки)
fstate = {"t0": 0.0, "total": 0, "done": 0}
@@ -241,13 +241,13 @@ def process_stream(sid):
kind = evt[0]
if kind == "progress":
_, phase, idx, name, total_, elapsed = evt
_, phase, idx, name, total_, elapsed, source_idxs = evt
log.debug("process_stream: event=%s idx=%d name=%r elapsed=%s sid=%s",
phase, idx, name, elapsed, sid)
try:
yield (
f"event: {phase}\n"
f"data: {json.dumps({'idx': idx, 'name': name, 'total': total_, 'elapsed': elapsed})}\n\n"
f"data: {json.dumps({'idx': idx, 'source_idxs': source_idxs, 'name': name, 'total': total_, 'elapsed': elapsed})}\n\n"
)
except _disconnect_exceptions() as e:
log.warning("process_stream: disconnect on progress sid=%s phase=%s err=%r", sid, phase, e)
@@ -259,7 +259,7 @@ def process_stream(sid):
elif kind == "file":
_, event, fname, fields = evt
# Обновляем состояние для глобальной ETA
if event == "extract_done":
if event == "extract_complete":
eta["total_chars"] = fields.get("total_chars", 0)
elif event == "file_start":
eta["cur_chars"] = fields.get("chars", 0)
+4 -3
View File
@@ -641,14 +641,15 @@ function renderNode(node2, depth, cfg) {
const name = esc(node2.name);
const action = isGroup ? `<button class="tree-name tree-group tree-${node2.kind}" style="padding-left:${padding}ch" data-toggle="${node2.id}" aria-expanded="${node2.expanded}"><span class="tree-chevron">${node2.expanded ? "\u25BC" : "\u25B6"}</span>${name}</button>` : `<span class="tree-name" style="padding-left:${padding}ch">${name}</span>`;
const remove = `<button class="remove-btn" type="button" data-remove="${node2.id}" aria-label="${esc(cfg.labels.remove)} ${name}">\xD7</button>`;
const pathAttribute = node2.kind === "file" ? ` data-path="${esc(node2.path)}"` : "";
const pathAttribute = ` data-path="${esc(node2.path)}"`;
const fileIdAttribute = node2.kind === "file" ? ` data-file-id="${esc(node2.id)}"` : "";
const cells = {
path: action,
size: node2.kind === "file" ? fs(node2.file.size) : "",
status: node2.error ? esc(node2.error) : node2.kind === "file" ? esc(cfg.labels.statusReady) : ""
};
const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
const row = `<tr class="tree-row tree-${node2.kind}"${pathAttribute}>` + columns.map((column) => `<td>${cells[column] || ""}</td>`).join("") + `<td>${remove}</td></tr>`;
const row = `<tr class="tree-row tree-${node2.kind}"${pathAttribute}${fileIdAttribute}>` + columns.map((column) => `<td>${cells[column] || ""}</td>`).join("") + `<td>${remove}</td></tr>`;
if (!isGroup || !node2.expanded) return row;
return row + node2.children.map((child) => renderNode(child, depth + 1, cfg)).join("");
}
@@ -675,7 +676,7 @@ function findNode(nodes, id) {
}
function flattenFiles(nodes, result = []) {
nodes.forEach((node2) => {
if (node2.kind === "file") result.push({ path: node2.path, name: node2.name, size: node2.file.size, file: node2.file });
if (node2.kind === "file") result.push({ id: node2.id, path: node2.path, name: node2.name, size: node2.file.size, file: node2.file });
else flattenFiles(node2.children, result);
});
return result;
+4 -3
View File
@@ -669,14 +669,15 @@ var FilePicker = (() => {
const name = esc(node2.name);
const action = isGroup ? `<button class="tree-name tree-group tree-${node2.kind}" style="padding-left:${padding}ch" data-toggle="${node2.id}" aria-expanded="${node2.expanded}"><span class="tree-chevron">${node2.expanded ? "\u25BC" : "\u25B6"}</span>${name}</button>` : `<span class="tree-name" style="padding-left:${padding}ch">${name}</span>`;
const remove = `<button class="remove-btn" type="button" data-remove="${node2.id}" aria-label="${esc(cfg.labels.remove)} ${name}">\xD7</button>`;
const pathAttribute = node2.kind === "file" ? ` data-path="${esc(node2.path)}"` : "";
const pathAttribute = ` data-path="${esc(node2.path)}"`;
const fileIdAttribute = node2.kind === "file" ? ` data-file-id="${esc(node2.id)}"` : "";
const cells = {
path: action,
size: node2.kind === "file" ? fs(node2.file.size) : "",
status: node2.error ? esc(node2.error) : node2.kind === "file" ? esc(cfg.labels.statusReady) : ""
};
const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
const row = `<tr class="tree-row tree-${node2.kind}"${pathAttribute}>` + columns.map((column) => `<td>${cells[column] || ""}</td>`).join("") + `<td>${remove}</td></tr>`;
const row = `<tr class="tree-row tree-${node2.kind}"${pathAttribute}${fileIdAttribute}>` + columns.map((column) => `<td>${cells[column] || ""}</td>`).join("") + `<td>${remove}</td></tr>`;
if (!isGroup || !node2.expanded) return row;
return row + node2.children.map((child) => renderNode(child, depth + 1, cfg)).join("");
}
@@ -703,7 +704,7 @@ var FilePicker = (() => {
}
function flattenFiles(nodes, result = []) {
nodes.forEach((node2) => {
if (node2.kind === "file") result.push({ path: node2.path, name: node2.name, size: node2.file.size, file: node2.file });
if (node2.kind === "file") result.push({ id: node2.id, path: node2.path, name: node2.name, size: node2.file.size, file: node2.file });
else flattenFiles(node2.children, result);
});
return result;
+288 -97
View File
@@ -375,6 +375,12 @@
display: block;
}
.status.cancelled {
background: #fff4e5;
color: #825500;
display: block;
}
.dl-btns {
display: none;
gap: 8px;
@@ -543,6 +549,9 @@
let busy = false;
let sessionDone = false;
let ptimer = null, liveRefresh = null;
let tableFiles = [];
const fileStatuses = new Map();
const groupMembers = new Map();
const picker = FilePicker.initFilePicker({
mount: '#filePicker',
@@ -552,7 +561,7 @@
pickFolder: 'Выбрать папку',
clear: 'Очистить',
empty: 'Нет выбранных файлов',
statusReady: 'готов',
statusReady: 'Выбран',
remove: '✕',
columns: { path: 'Имя', size: 'Размер', status: 'Статус' },
},
@@ -563,11 +572,123 @@
maxDepth: 20,
},
onChange: (files) => {
tableFiles = files;
groupMembers.clear();
files.forEach((file) => {
const path = file.path || file.name;
const parts = path.split('/');
for (let depth = 1; depth < parts.length; depth++) {
const groupPath = parts.slice(0, depth).join('/');
if (!groupMembers.has(groupPath)) groupMembers.set(groupPath, []);
groupMembers.get(groupPath).push(file.id);
}
});
if (!busy) {
fileStatuses.clear();
renderFileStatuses();
}
ub.disabled = files.length === 0 || busy;
fc.textContent = files.length ? `${files.length} файл(ов)` : '0 файлов';
fc.textContent = formatFileCount(files.length);
}
});
function renderGroupStatus(row) {
const statusCell = row.querySelectorAll('td')[2];
if (!statusCell) return;
const fileIds = groupMembers.get(row.dataset.path) || [];
const statuses = fileIds.map((id) => fileStatuses.get(id)).filter(Boolean);
if (!statuses.length) {
statusCell.textContent = fileIds.length ? `Выбрано: ${formatFileCount(fileIds.length)}` : '';
return;
}
const total = fileIds.length;
const completed = statuses.filter((status) => status.terminal).length;
const successful = statuses.filter((status) => status.outcome === 'success').length;
const duplicates = statuses.filter((status) => status.outcome === 'duplicate').length;
const errors = statuses.filter((status) => status.outcome === 'error').length;
const stopped = statuses.filter((status) => status.outcome === 'cancelled').length;
const uploading = statuses.filter((status) => status.outcome === 'uploaded').length;
const activeStatuses = statuses.filter((status) => status.outcome === 'progress');
const active = activeStatuses.length;
if (completed === total && successful + duplicates === total) {
statusCell.textContent = `Готово: ${formatFileCount(successful)}`;
if (duplicates) statusCell.textContent += `, дубликатов: ${duplicates}`;
} else if (completed === total && uploading === total) {
statusCell.textContent = `Загружено: ${formatFileCount(uploading)}`;
} else if (completed === total) {
statusCell.textContent = `Завершено: ${completed}/${total}`
+ (errors ? `, ошибок: ${errors}` : '')
+ (stopped ? `, остановлено: ${stopped}` : '')
+ (uploading ? `, загружено: ${uploading}` : '');
} else if (active) {
const stage = activeStatuses[0].text.split(' · ')[0];
statusCell.textContent = `${stage}: ${active} в работе, завершено ${completed}/${total}`;
} else if (uploading) {
statusCell.textContent = `Загружено: ${uploading}/${total}`;
} else {
statusCell.textContent = `В очереди: ${total}`;
}
}
function renderFileStatuses() {
const rows = document.querySelectorAll('#filePicker .tree-row');
rows.forEach((row) => {
const statusCell = row.querySelectorAll('td')[2];
if (!statusCell) return;
if (row.classList.contains('tree-file')) {
const status = fileStatuses.get(row.dataset.fileId);
if (status) statusCell.textContent = status.text;
return;
}
if (!row.classList.contains('tree-folder') && !row.classList.contains('tree-zip')) return;
renderGroupStatus(row);
});
}
function setFileStatus(index, text, outcome = 'progress', terminal = false) {
const file = tableFiles[index];
if (!file) return;
const fileId = file.id;
const path = file.path || file.name;
fileStatuses.set(fileId, { text, outcome, terminal });
document.querySelectorAll('#filePicker .tree-row.tree-file').forEach((row) => {
if (row.dataset.fileId === fileId) {
const statusCell = row.querySelectorAll('td')[2];
if (statusCell) statusCell.textContent = text;
}
});
const parts = path.split('/');
for (let depth = 1; depth < parts.length; depth++) {
const groupPath = parts.slice(0, depth).join('/');
document.querySelectorAll('#filePicker .tree-row.tree-folder, #filePicker .tree-row.tree-zip')
.forEach((row) => {
if (row.dataset.path === groupPath) renderGroupStatus(row);
});
}
}
function setEventFileStatus(data, text, outcome = 'progress', terminal = false) {
const indexes = Array.isArray(data.source_idxs) && data.source_idxs.length
? data.source_idxs : [data.idx];
indexes.forEach((index, position) => {
if (position === 0) {
setFileStatus(index, text, outcome, terminal);
return;
}
const duplicateSuccess = terminal && outcome === 'success';
setFileStatus(
index,
duplicateSuccess ? 'Дубликат: результат включён один раз' : `Дубликат: ${text}`,
duplicateSuccess ? 'duplicate' : outcome,
terminal
);
});
}
document.getElementById('filePicker').addEventListener('click', (event) => {
if (event.target.closest('[data-toggle]')) queueMicrotask(renderFileStatuses);
});
function setBusy(b) {
busy = b;
document.body.classList.toggle('busy', b);
@@ -578,6 +699,15 @@
return s >= 60 ? Math.floor(s / 60) + 'м ' + (s % 60) + 'с' : s + 'с';
}
function formatFileCount(count) {
const mod100 = count % 100;
const mod10 = count % 10;
const unit = mod100 >= 11 && mod100 <= 14 ? 'файлов'
: mod10 === 1 ? 'файл'
: mod10 >= 2 && mod10 <= 4 ? 'файла' : 'файлов';
return `${count} ${unit}`;
}
function confirmCancel() {
document.getElementById('cancelModal').style.display = 'flex';
}
@@ -653,17 +783,27 @@
signal: abortCtrl.signal,
onStatus: (msg) => {
st.className = 'status progress';
st.textContent = msg;
st.textContent = msg
.replace('Приём сервисом', 'Сохранение в сессию')
.replace('Завершено. Успешно передано файлов:', 'Загрузка завершена. Передано файлов:');
},
onFileStatus: (k, statusText) => {
const rows = document.querySelectorAll('.tree-row.tree-file');
if (rows[k]) {
const td = rows[k].querySelectorAll('td')[2];
if (td) td.innerHTML = statusText;
}
const text = statusText
.replace(/^⏳ В буфер\.\.\./, 'Загрузка в буфер')
.replace(/^⏳ (\d+%.*)$/, 'Загрузка в буфер · $1')
.replace(/^⚡ В сессию\.\.\./, 'Сохранение в сессию')
.replace(/^✓ Доставлен$/, '✓ Загружен');
const outcome = /ошибка/i.test(text) ? 'error'
: text === '✓ Загружен' ? 'uploaded' : 'progress';
setFileStatus(k, text, outcome, outcome === 'error' || outcome === 'uploaded');
}
});
} catch (err) {
tableFiles.forEach((file, index) => {
if (!fileStatuses.get(file.id)?.terminal) {
setFileStatus(index, 'Не загружен: ошибка передачи', 'error', true);
}
});
st.className = 'status error';
st.textContent = 'Ошибка загрузки: ' + err.message;
setBusy(false);
@@ -674,9 +814,19 @@
if (!res.ok) {
if (res.aborted) {
st.className = 'status';
tableFiles.forEach((_, index) => {
if (!fileStatuses.get(tableFiles[index].id)?.terminal) {
setFileStatus(index, 'Не загружен: передача остановлена', 'cancelled', true);
}
});
st.className = 'status cancelled';
st.textContent = 'Загрузка отменена пользователем.';
} else {
tableFiles.forEach((_, index) => {
if (!fileStatuses.get(tableFiles[index].id)?.terminal) {
setFileStatus(index, 'Не загружен: передача прервана', 'error', true);
}
});
st.className = 'status error';
st.textContent = res.error || 'Ошибка загрузки';
}
@@ -691,7 +841,7 @@
if (TEST_UPLOAD_ONLY) {
st.className = 'status done';
st.textContent = 'Загружено ' + uploadedCount + ' файлов (тест: обработка пропущена). Сессия: ' + currentSid;
st.textContent = 'Загружено ' + formatFileCount(uploadedCount) + ' (тест: обработка пропущена). Сессия: ' + currentSid;
setBusy(false);
cb.style.display = 'none';
ub.disabled = false;
@@ -704,14 +854,18 @@
async function startProcessingPhase(total, files) {
st.className = 'status progress';
st.textContent = 'Обработка (этап 2/2)…';
st.textContent = 'Обработка: подготовка';
cb.style.display = 'inline-block';
const rows = document.querySelectorAll('.tree-row.tree-file');
rows.forEach(r => {
const td = r.querySelectorAll('td')[2];
if (td) td.innerHTML = '<span style="color:#2563eb">⏳</span>';
fileStatuses.clear();
files.forEach((file) => {
fileStatuses.set(file.id, {
text: 'В очереди',
outcome: 'queued',
terminal: false
});
});
renderFileStatuses();
const liveBlock = document.getElementById('liveBlock');
const liveTimerEl = document.getElementById('liveTimer');
@@ -720,6 +874,7 @@
const liveLlmTime = document.getElementById('liveLlmTime');
const liveEta = document.getElementById('liveEta');
let currentLiveFile = '';
let currentStage = 'Обработка: подготовка';
liveLlm.classList.remove('show');
liveEta.textContent = '';
liveTimerEl.textContent = '0.0 с';
@@ -730,7 +885,7 @@
if (ptimer) clearInterval(ptimer);
ptimer = setInterval(() => {
const sec = Math.round((performance.now() - t0) / 1000);
st.textContent = 'Обработка (этап 2/2)… ' + sec + 'с';
st.textContent = currentStage + ' · ' + sec + ' с';
}, 1000);
if (liveRefresh) clearInterval(liveRefresh);
@@ -739,106 +894,147 @@
liveTimerEl.textContent = sec + ' с';
}, 200);
let procExtractDone = false;
const fileTimers = {};
function updateStage(label, d, detail = '') {
const index = Array.isArray(d.source_idxs) && d.source_idxs.length
? d.source_idxs[0] : d.idx;
const file = files[index];
const fileName = file ? (file.path || file.name) : d.name;
const fileLabel = `файл ${index + 1}/${total}: ${fileName}`;
currentStage = `${label} · ${fileLabel}${detail ? ' · ' + detail : ''}`;
st.textContent = currentStage + ' · ' + Math.round((performance.now() - t0) / 1000) + ' с';
currentLiveFile = fileLabel;
liveFileEl.textContent = currentStage;
}
function setCurrentRow(index) {
const fileId = files[index] && files[index].id;
const rows = document.querySelectorAll('#filePicker .tree-row.tree-file');
rows.forEach((row) => row.classList.remove('row-current'));
const row = Array.from(rows).find((candidate) => candidate.dataset.fileId === fileId);
if (row) row.classList.add('row-current');
}
function markUnfinished(text, outcome) {
files.forEach((file) => {
if (!fileStatuses.get(file.id)?.terminal) {
fileStatuses.set(file.id, { text, outcome, terminal: true });
}
});
renderFileStatuses();
}
function getProcessingCounts() {
const statuses = files.map((file) => fileStatuses.get(file.id));
return {
completed: statuses.filter((status) => status?.outcome === 'success').length,
duplicates: statuses.filter((status) => status?.outcome === 'duplicate').length,
errors: statuses.filter((status) => status?.outcome === 'error').length,
stopped: statuses.filter((status) => status?.outcome === 'cancelled').length
};
}
function showStats(data, totalSec) {
const stats = document.getElementById('statsBlock');
document.getElementById('stTotalTime').textContent = totalSec + ' с';
if (data.llm_sec > 0) {
document.getElementById('stLlmTime').textContent = data.llm_sec + ' с';
document.getElementById('stLlmTokens').textContent = data.tokens > 0 ? data.tokens : '—';
} else {
document.getElementById('stLlmTime').textContent = '—';
document.getElementById('stLlmTokens').textContent = '—';
}
stats.classList.add('show');
}
try {
await new Promise((resolve, reject) => {
activeES = new EventSource('/api/process_stream/' + currentSid);
activeES.addEventListener('start', function (e) {
activeES.addEventListener('extract_start', function (e) {
const d = JSON.parse(e.data);
const idx = d.idx;
const curRows = document.querySelectorAll('.tree-row.tree-file');
if (curRows[idx]) {
curRows.forEach(r => r.classList.remove('row-current'));
curRows[idx].classList.add('row-current');
const td = curRows[idx].querySelectorAll('td')[2];
if (td) td.innerHTML = '<span style="color:var(--brand-primary)">…</span>';
}
currentLiveFile = 'Файл ' + (d.idx + 1) + '/' + total + ': ' + d.name;
liveFileEl.textContent = currentLiveFile;
setEventFileStatus(d, 'Извлечение текста');
setCurrentRow(d.source_idxs?.[0] ?? d.idx);
updateStage('Извлечение текста', d);
});
activeES.addEventListener('extract_done', function (e) {
procExtractDone = true;
const curRows = document.querySelectorAll('.tree-row.tree-file');
curRows.forEach(r => r.classList.remove('row-current'));
const d = JSON.parse(e.data);
setEventFileStatus(d, 'Текст извлечён, ожидает анализа');
updateStage('Извлечение текста', d, 'завершено');
});
activeES.addEventListener('extract_failed', function (e) {
const d = JSON.parse(e.data);
setEventFileStatus(d, 'Не извлечён', 'error', true);
updateStage('Извлечение текста', d, 'ошибка');
});
activeES.addEventListener('extract_complete', function () {
document.querySelectorAll('#filePicker .row-current')
.forEach((row) => row.classList.remove('row-current'));
currentStage = 'Анализ текста';
st.textContent = currentStage;
});
activeES.addEventListener('file_start', function (e) {
const d = JSON.parse(e.data);
const idx = d.idx;
fileTimers[idx] = performance.now();
const curRows = document.querySelectorAll('.tree-row.tree-file');
if (curRows[idx]) {
curRows.forEach(r => r.classList.remove('row-current'));
curRows[idx].classList.add('row-current');
const td = curRows[idx].querySelectorAll('td')[2];
if (td) td.innerHTML = '<span style="color:var(--brand-primary)">анализ…</span>';
}
liveFileEl.textContent = 'Файл ' + (d.idx + 1) + '/' + total + ': ' + d.name;
setEventFileStatus(d, 'Анализ текста');
setCurrentRow(d.source_idxs?.[0] ?? d.idx);
liveEta.textContent = '';
updateStage('Анализ текста', d);
});
activeES.addEventListener('file_chunk', function (e) {
const d = JSON.parse(e.data);
liveFileEl.textContent = 'Файл ' + (d.idx + 1) + '/' + total + ': ' + d.name +
(typeof d.eta_sec === 'number' ? ' — осталось ~' + fmtSec(d.eta_sec) : '');
const detail = `фрагмент ${d.chunks_done}/${d.chunks_total}`;
setEventFileStatus(d, `Анализ текста · ${d.chunks_done}/${d.chunks_total}`);
updateStage('Анализ текста', d, detail);
if (typeof d.eta_sec === 'number') liveEta.textContent = 'Осталось около ' + fmtSec(d.eta_sec);
else liveEta.textContent = '';
});
activeES.addEventListener('file_done', function (e) {
const d = JSON.parse(e.data);
const idx = d.idx;
const curRows = document.querySelectorAll('.tree-row.tree-file');
if (curRows[idx]) {
curRows[idx].classList.remove('row-current');
const td = curRows[idx].querySelectorAll('td')[2];
if (td) td.innerHTML = '<span style="color:var(--green)">готов</span>';
}
setEventFileStatus(d, 'Анализ завершён, ожидает замены');
setCurrentRow(-1);
updateStage('Анализ текста', d, 'завершён');
});
activeES.addEventListener('replace_start', function (e) {
const d = JSON.parse(e.data);
setEventFileStatus(d, 'Замена');
setCurrentRow(d.source_idxs?.[0] ?? d.idx);
updateStage('Замена', d);
});
activeES.addEventListener('replace_failed', function (e) {
const d = JSON.parse(e.data);
setEventFileStatus(d, 'Ошибка замены', 'error', true);
updateStage('Замена', d, 'ошибка');
});
activeES.addEventListener('replace_done', function (e) {
const d = JSON.parse(e.data);
const sec = typeof d.elapsed === 'number' ? d.elapsed.toFixed(1) : '—';
setEventFileStatus(d, `✓ Готово · ${sec} с`, 'success', true);
setCurrentRow(-1);
updateStage('Замена', d, 'завершено');
});
activeES.addEventListener('llm', function (e) {
const d = JSON.parse(e.data);
if (d.active) {
liveLlmTime.textContent = d.elapsed + ' с';
liveLlm.classList.add('show');
liveFileEl.textContent = 'ИИ анализирует все файлы…';
if (!currentStage.startsWith('Анализ текста')) {
currentStage = 'Анализ текста';
}
liveFileEl.textContent = currentStage;
if (typeof d.eta_sec === 'number' && d.eta_sec >= 0) liveEta.textContent = '⏳ осталось ~' + fmtSec(d.eta_sec);
else liveEta.textContent = '';
} else {
liveLlm.classList.remove('show');
if (currentLiveFile) liveFileEl.textContent = currentLiveFile;
}
});
activeES.addEventListener('done', function (e) {
const d = JSON.parse(e.data);
const idx = d.idx;
const curRows = document.querySelectorAll('.tree-row.tree-file');
if (!procExtractDone) {
if (curRows[idx]) {
const td = curRows[idx].querySelectorAll('td')[2];
if (td) td.innerHTML = '<span style="color:#c0392b;">не извлечён</span>';
}
return;
}
const sec = (typeof d.elapsed === 'number' && d.elapsed > 0)
? d.elapsed.toFixed(1)
: ((performance.now() - (fileTimers[idx] || performance.now())) / 1000).toFixed(1);
if (curRows[idx]) {
const td = curRows[idx].querySelectorAll('td')[2];
if (td) td.innerHTML = '<span style="color:var(--green)">' + sec + 'с</span>';
if (currentLiveFile) liveFileEl.textContent = currentStage;
}
});
activeES.addEventListener('cancelled', function (e) {
const d = JSON.parse(e.data);
markUnfinished('Остановлено до завершения', 'cancelled');
finishProcUI();
const totalSec = ((performance.now() - t0) / 1000).toFixed(1);
st.className = 'status done';
st.textContent = 'Остановлено. Сохранено ' + d.saved + ' из ' + d.total + ' файлов + таблица замен (общее ' + totalSec + 'с)';
const sb = document.getElementById('statsBlock');
document.getElementById('stTotalTime').textContent = totalSec + ' с';
if (d.llm_sec > 0) {
document.getElementById('stLlmTime').textContent = d.llm_sec + ' с';
document.getElementById('stLlmTokens').textContent = d.tokens > 0 ? d.tokens : '—';
} else {
document.getElementById('stLlmTime').textContent = '—';
document.getElementById('stLlmTokens').textContent = '—';
}
sb.classList.add('show');
const counts = getProcessingCounts();
st.className = 'status cancelled';
st.textContent = `Остановлено: готово ${counts.completed} из ${files.length}, дубликатов ${counts.duplicates}, ошибок ${counts.errors}, остановлено ${counts.stopped}; сохранено ${formatFileCount(d.saved)} · ${totalSec} с`;
showStats(d, totalSec);
db.classList.add('show');
sessionDone = true;
ub.disabled = true;
@@ -846,21 +1042,14 @@
resolve();
});
activeES.addEventListener('complete', function (e) {
finishProcUI();
const d = JSON.parse(e.data);
const totalSec = ((performance.now() - t0) / 1000).toFixed(1);
st.className = 'status done';
st.textContent = 'Обработано ' + d.total + ' файлов: общее ' + totalSec + 'с, из них ИИ ' + (d.llm_sec > 0 ? d.llm_sec + 'с' : '0с');
const sb = document.getElementById('statsBlock');
document.getElementById('stTotalTime').textContent = totalSec + ' с';
if (d.llm_sec > 0) {
document.getElementById('stLlmTime').textContent = d.llm_sec + ' с';
document.getElementById('stLlmTokens').textContent = d.tokens > 0 ? d.tokens : '—';
} else {
document.getElementById('stLlmTime').textContent = '—';
document.getElementById('stLlmTokens').textContent = '—';
}
sb.classList.add('show');
markUnfinished('Не обработан: статус результата не получен', 'error');
finishProcUI();
const counts = getProcessingCounts();
st.className = counts.errors ? 'status error' : 'status done';
st.textContent = `Готово: ${counts.completed} из ${files.length}, дубликатов: ${counts.duplicates}, ошибок: ${counts.errors} · ${totalSec} с`;
showStats(d, totalSec);
db.classList.add('show');
sessionDone = true;
ub.disabled = true;
@@ -869,6 +1058,7 @@
});
activeES.addEventListener('proc_error', function (e) {
const d = JSON.parse(e.data);
markUnfinished('Не завершён: ошибка обработки', 'error');
finishProcUI();
setBusy(false);
sessionDone = false;
@@ -882,6 +1072,7 @@
resolve();
});
activeES.onerror = function () {
markUnfinished('Не завершён: потеряно соединение', 'error');
finishProcUI();
reject(new Error('SSE connection failed'));
};