diff --git a/drhider/obfuscator.py b/drhider/obfuscator.py index 33fb50a..1f00243 100644 --- a/drhider/obfuscator.py +++ b/drhider/obfuscator.py @@ -32,7 +32,7 @@ class LLMIncompleteError(Exception): def _dedupe_file_names( files: List[Tuple[str, bytes, str]] -) -> List[Tuple[str, bytes, str]]: +) -> Tuple[List[Tuple[str, bytes, str]], List[List[int]]]: """Уникализировать имена файлов перед обработкой. Точный дубль (одинаковое имя + одинаковое содержимое) пропускается. @@ -42,17 +42,24 @@ def _dedupe_file_names( files: [(filename, content_bytes, content_type), ...] Returns: - Новый список с уникальными именами (порядок сохранён). + Новый список с уникальными именами (порядок сохранён) и исходные индексы, + соответствующие каждому файлу. Точные дубликаты ссылаются на один результат. """ seen: Dict[str, bytes] = {} out: List[Tuple[str, bytes, str]] = [] + source_indices: List[List[int]] = [] + source_to_output: Dict[Tuple[str, bytes], int] = {} - for fname, content, ctype in files: + for source_idx, (fname, content, ctype) in enumerate(files): candidate = resolve_name(fname, content, seen) if candidate is not None: out.append((candidate, content, ctype)) + source_to_output[(fname, content)] = len(out) - 1 + source_indices.append([source_idx]) + else: + source_indices[source_to_output[(fname, content)]].append(source_idx) - return out + return out, source_indices class TwoPassObfuscator: @@ -89,7 +96,7 @@ class TwoPassObfuscator: def obfuscate( self, files: List[Tuple[str, bytes, str]], - progress_cb: Optional[Callable[[str, int, int, str], None]] = None, + progress_cb: Optional[Callable[[str, int, int, str, float, List[int]], None]] = None, cancel_event=None, file_progress=None ) -> Tuple[bytes, str, Optional[dict]]: """Обфусцировать список файлов. @@ -98,14 +105,18 @@ class TwoPassObfuscator: Args: files: [(filename, content_bytes, content_type), ...] - progress_cb: Опциональный коллбек (phase, idx, total, fname, elapsed), - где phase ∈ {"start", "done"}, idx — 0-based индекс, - elapsed — время обработки конкретного файла (сек). + progress_cb: Опциональный коллбек (phase, idx, total, fname, elapsed, source_idxs), + phase — extract_start/extract_done/extract_failed или + replace_start/replace_done/replace_failed; idx — 0-based индекс, + elapsed — время обработки конкретного файла (сек), + source_idxs — индексы строк исходного списка, включая дубликаты. cancel_event: threading.Event — мягкая остановка. Проверяется между файлами LLM и между файлами замены; текущий файл добирается. file_progress: callable(event, fname, **fields) — прогресс по файлам/чанкам. - События: extract_done{total_chars}, file_start{chars,chunks,idx}, - file_chunk{chunks_done,chunks_total,idx}, file_done{elapsed,idx}. + События: extract_complete{total_chars}, + file_start{chars,chunks,idx,source_idxs}, + file_chunk{chunks_done,chunks_total,idx,source_idxs}, + file_done{elapsed,idx,source_idxs}. Returns: (zip_bytes, csv_string, meta): @@ -116,7 +127,7 @@ class TwoPassObfuscator: """ # ── Предобработка: распаковать ZIP + уникализировать имена ── files = extractor.expand_zips(files) - files = _dedupe_file_names(files) + files, source_indices = _dedupe_file_names(files) # имя файла (внутр. ключ all_texts) -> idx в files (для событий file_progress) name_to_idx = {fname: i for i, (fname, _, _) in enumerate(files)} @@ -126,7 +137,9 @@ class TwoPassObfuscator: if event == "file_done": llm_done.add(fname) if file_progress: - fields["idx"] = name_to_idx.get(fname) + idx = name_to_idx.get(fname) + fields["idx"] = idx + fields["source_idxs"] = source_indices[idx] if idx is not None else [] file_progress(event, fname, **fields) try: @@ -150,7 +163,7 @@ class TwoPassObfuscator: break display_name = os.path.basename(fname) or fname if progress_cb: - progress_cb("start", i, total, display_name, 0.0) + progress_cb("extract_start", i, total, display_name, 0.0, source_indices[i]) t0 = time.time() try: @@ -159,9 +172,11 @@ class TwoPassObfuscator: log.warning("obfuscate: skip file %r: %r", fname, e) skipped.add(fname) if progress_cb: - progress_cb("done", i, total, display_name, 0.0) + progress_cb("extract_failed", i, total, display_name, 0.0, source_indices[i]) continue all_texts[fname] = text + if progress_cb: + progress_cb("extract_done", i, total, display_name, 0.0, source_indices[i]) # Regex-сканирование (быстрое, локальное) if text and not text.startswith("[DOC binary"): @@ -171,7 +186,7 @@ class TwoPassObfuscator: # Объём извлечённого текста — для оценки времени (глобальной ETA) total_chars = sum(len(t) for t in all_texts.values()) if file_progress: - file_progress("extract_done", None, total_chars=total_chars, + file_progress("extract_complete", None, total_chars=total_chars, per_file={fname: len(t) for fname, t in all_texts.items()}) # LLM-сканирование (получает уже найденное regex'ом чтобы не дублировать) @@ -208,8 +223,6 @@ class TwoPassObfuscator: # Битый файл (не удалось извлечь текст) — пропускаем if fname in skipped: - if progress_cb: - progress_cb("done", i, total, display_name, 0.0) continue # При прерывании в LLM — в результат идут ТОЛЬКО файлы с завершённым LLM @@ -223,6 +236,8 @@ class TwoPassObfuscator: cancelled = True break + if progress_cb: + progress_cb("replace_start", i, total, display_name, 0.0, source_indices[i]) t0 = time.time() obf_content = content # По умолчанию — без изменений @@ -235,7 +250,7 @@ class TwoPassObfuscator: log.warning("obfuscate: skip CSV %r during replacement: %r", fname, e) skipped.add(fname) if progress_cb: - progress_cb("done", i, total, display_name, 0.0) + progress_cb("replace_failed", i, total, display_name, 0.0, source_indices[i]) continue elif fname.endswith('.doc'): # .doc — конвертируется через liberta, обрабатывается как .docx @@ -260,7 +275,10 @@ class TwoPassObfuscator: file_times[i] += time.time() - t0 results.append((fname, obf_content)) if progress_cb: - progress_cb("done", i, total, display_name, round(file_times[i], 2)) + progress_cb( + "replace_done", i, total, display_name, + round(file_times[i], 2), source_indices[i], + ) # ── Сборка результата ── csv_str = builder.build_mapping_csv(self._mapping) @@ -286,7 +304,7 @@ class TwoPassObfuscator: def obfuscate_files( files: List[Tuple[str, bytes, str]], llm_client=None, - progress_cb: Optional[Callable[[str, int, int, str], None]] = None, + progress_cb: Optional[Callable[[str, int, int, str, float, List[int]], None]] = None, cancel_event=None, file_progress=None ) -> Tuple[bytes, str, Optional[dict]]: """Обфусцировать список файлов — удобная функция. @@ -296,7 +314,7 @@ def obfuscate_files( Args: files: [(filename, content_bytes, content_type), ...] llm_client: Опциональный LLM-клиент - progress_cb: Опциональный коллбек (phase, idx, total, fname) + progress_cb: Опциональный коллбек (phase, idx, total, fname, elapsed, source_idxs) cancel_event: threading.Event — мягкая остановка (см. TwoPassObfuscator.obfuscate) file_progress: callable(event, fname, **fields) — прогресс по файлам/чанкам diff --git a/drhider/scanner.py b/drhider/scanner.py index 3f6bfc1..60be3e6 100644 --- a/drhider/scanner.py +++ b/drhider/scanner.py @@ -14,7 +14,7 @@ import json import random import time import logging -from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import ThreadPoolExecutor, as_completed from typing import Dict, List, Optional from .config import ( @@ -294,13 +294,22 @@ def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str], with ThreadPoolExecutor(max_workers=_LLM_CONCURRENCY) as _ex: futures = [_ex.submit(_call_llm, _build_llm_prompt(c, mapping.keys()), llm_client) for c in chunks] - all_ent = [] - for fut in futures: + chunk_results = [None] * len(futures) + future_indexes = {future: index for index, future in enumerate(futures)} + completed = 0 + for fut in as_completed(futures): + index = future_indexes[fut] try: - all_ent.extend(fut.result()) + chunk_results[index] = fut.result() except Exception as e: llm_failures += 1 log.warning("LLM NER chunk failed in %r: %s", fname, e) + chunk_results[index] = [] + completed += 1 + if file_progress: + file_progress("file_chunk", fname, + chunks_done=completed, chunks_total=len(chunks)) + all_ent = [ent for chunk_result in chunk_results for ent in chunk_result] else: all_ent = [] for k, c in enumerate(chunks): diff --git a/site/app.py b/site/app.py index c931018..13d634f 100644 --- a/site/app.py +++ b/site/app.py @@ -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(): diff --git a/site/routes/api_bp.py b/site/routes/api_bp.py index 01af493..712d18b 100644 --- a/site/routes/api_bp.py +++ b/site/routes/api_bp.py @@ -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) diff --git a/site/static/dist/file-picker.esm.js b/site/static/dist/file-picker.esm.js index 1d3cc0c..5ece313 100644 --- a/site/static/dist/file-picker.esm.js +++ b/site/static/dist/file-picker.esm.js @@ -641,14 +641,15 @@ function renderNode(node2, depth, cfg) { const name = esc(node2.name); const action = isGroup ? `` : `${name}`; const remove = ``; - 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 = `` + columns.map((column) => `${cells[column] || ""}`).join("") + `${remove}`; + const row = `` + columns.map((column) => `${cells[column] || ""}`).join("") + `${remove}`; 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; diff --git a/site/static/dist/file-picker.iife.js b/site/static/dist/file-picker.iife.js index 67d8617..55bfef4 100644 --- a/site/static/dist/file-picker.iife.js +++ b/site/static/dist/file-picker.iife.js @@ -669,14 +669,15 @@ var FilePicker = (() => { const name = esc(node2.name); const action = isGroup ? `` : `${name}`; const remove = ``; - 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 = `` + columns.map((column) => `${cells[column] || ""}`).join("") + `${remove}`; + const row = `` + columns.map((column) => `${cells[column] || ""}`).join("") + `${remove}`; 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; diff --git a/site/templates/index.html b/site/templates/index.html index 17de0f3..49142c0 100644 --- a/site/templates/index.html +++ b/site/templates/index.html @@ -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 = '⏳'; + 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 = '…'; - } - 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 = 'анализ…'; - } - 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 = 'готов'; - } + 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 = 'не извлечён'; - } - 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 = '' + sec + 'с'; + 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')); }; diff --git a/upload/frontend/table/render.js b/upload/frontend/table/render.js index 76b8967..ae33bf8 100644 --- a/upload/frontend/table/render.js +++ b/upload/frontend/table/render.js @@ -46,7 +46,8 @@ function renderNode(node, depth, cfg) { + `aria-expanded="${node.expanded}">${node.expanded ? '▼' : '▶'}${name}` : `${name}`; const remove = ``; - const pathAttribute = node.kind === 'file' ? ` data-path="${esc(node.path)}"` : ''; + const pathAttribute = ` data-path="${esc(node.path)}"`; + const fileIdAttribute = node.kind === 'file' ? ` data-file-id="${esc(node.id)}"` : ''; const cells = { path: action, size: node.kind === 'file' ? fs(node.file.size) : '', @@ -55,7 +56,7 @@ function renderNode(node, depth, cfg) { const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(DEFAULT_RENDER_CONFIG.labels.columns); - const row = `` + const row = `` + columns.map((column) => `${cells[column] || ''}`).join('') + `${remove}`; if (!isGroup || !node.expanded) return row; @@ -113,11 +114,11 @@ export function findNode(nodes, id) { * * @param {Array} nodes Узлы любого уровня. * @param {Array} result Внутренний аккумулятор рекурсивного вызова. - * @returns {Array<{path: string, name: string, size: number, file: File}>} Файлы для приложения. + * @returns {Array<{id: string, path: string, name: string, size: number, file: File}>} Файлы для приложения. */ export function flattenFiles(nodes, result = []) { nodes.forEach((node) => { - if (node.kind === 'file') result.push({ path: node.path, name: node.name, size: node.file.size, file: node.file }); + if (node.kind === 'file') result.push({ id: node.id, path: node.path, name: node.name, size: node.file.size, file: node.file }); else flattenFiles(node.children, result); }); return result;