v0.0.63: TTL-фикс, прерывание с сохранением, трекинг файлов/чанков, ETA (global+per-file), UI-таблица 3 секции + кнопка Прервать
Deploy drhider / validate (push) Canceled after 0s

This commit is contained in:
“Naeel”
2026-08-24 14:59:03 +03:00
parent 0e5f3ec9a1
commit 25e4b46e76
7 changed files with 581 additions and 108 deletions
@@ -0,0 +1,49 @@
# Реализовано: TTL-фикс + прерывание с сохранением + ETA + UI-таблица (v0.0.63)
_2026-08-24. По плану `2026-08-24-implementation-plan-for-flash.md`. Код написан (роль Flash)._
## Что сделано
### session.py — TTL-фикс + отмена
- `touch(sid)`, `pause_ttl(sid)`, `resume_ttl(sid)` — продление/пауза/возобновление TTL.
- В сессии `"cancel": threading.Event()`; `request_cancel(sid)`, `get_cancel_event(sid)`.
### scanner.py — прогресс и мягкая остановка LLM
- `class CancelRequested`.
- `scan_llm_ner(..., cancel_event, file_progress)`: отмена ТОЛЬКО между файлами (текущий добирается);
события `file_start{chars,chunks}`, `file_chunk{chunks_done,chunks_total}`, `file_done{elapsed}`.
### obfuscator.py — частичный результат
- `obfuscate()` возвращает `(zip, csv, meta)`: `meta=None` (штатно) или
`{"cancelled": True, "processed", "total"}` (прерывание).
- Трекинг `llm_done` (файлы с завершённым LLM). При отмене в LLM — в результат идут
ТОЛЬКО файлы из `llm_done` (их обфускация корректна); при отмене в фазе замены —
стоп после текущего файла. Отмена в replace НЕ проверяется, если LLM уже прерван
(файлы из llm_done добираются).
- Событие `extract_done{total_chars, per_file}` (объём текста для ETA).
### api_bp.py — API
- `POST /api/cancel/<sid>`.
- `process_stream`: `pause_ttl` в начале / `resume_ttl` в `finally`; передача `cancel_event`
и `file_progress` в воркер; события `extract_done/file_start/file_chunk/file_done/cancelled`;
heartbeat `llm` расширен: `eta_sec, done_chars, total_chars`; per-file ETA в `file_chunk`.
- При закрытии вкладки (`disconnect`) ставится и `cancel`, и `cancel_event` (воркер останавливается).
- legacy `process()` — фикс 3-значного возврата + TTL.
### index.html — UI
- Кнопка «⏹ Прервать» + модал-подтверждение (объясняет, что сохранится).
- Таблица 3 секций: «✓ Обработанные» (факт время) / «▶ Текущий файл» (прошло / ~осталось) /
«○ Ожидают» (~оценка из скорости текущего файла).
- Live-блок: «осталось ~X» (глобальная ETA), текущий файл с per-file ETA.
- SSE-обработчики: extract_done/file_start/file_chunk/file_done/cancelled; done различает
пропущенные (до extract_done) и готовые (после).
## Проверка
- `py_compile` всех .py — OK; `node --check` (JS из index.html) — OK; `get_errors` — нет.
- Локальные тесты логики отмены (FakeLLM): штатно=4 файла; отмена в LLM=3 сохранено;
отмена до LLM=0..1 (текущий добирается); отмена после завершения=полный.
- Смоук-тест: приложение создаётся, все роуты включая `/api/cancel/<sid>` зарегистрированы.
## ВАЖНО
- Версия 0.0.63. **Код не задеплоен** — нужен редеплой на кластере и прогон реальных тестов
(долгий прогон + прерывание).
+63 -10
View File
@@ -88,8 +88,9 @@ class TwoPassObfuscator:
def obfuscate( def obfuscate(
self, files: List[Tuple[str, bytes, str]], 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], None]] = None,
) -> Tuple[bytes, str]: cancel_event=None, file_progress=None
) -> Tuple[bytes, str, Optional[dict]]:
"""Обфусцировать список файлов. """Обфусцировать список файлов.
Все форматы → Markdown → замена → результат. Все форматы → Markdown → замена → результат.
@@ -99,16 +100,34 @@ class TwoPassObfuscator:
progress_cb: Опциональный коллбек (phase, idx, total, fname, elapsed), progress_cb: Опциональный коллбек (phase, idx, total, fname, elapsed),
где phase ∈ {"start", "done"}, idx — 0-based индекс, где phase ∈ {"start", "done"}, idx — 0-based индекс,
elapsed — время обработки конкретного файла (сек). elapsed — время обработки конкретного файла (сек).
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}.
Returns: Returns:
(zip_bytes, csv_string): (zip_bytes, csv_string, meta):
zip_bytes — ZIP-архив с обфусцированными .md файлами + mapping.csv zip_bytes — ZIP-архив с обфусцированными .md файлами (без mapping.csv)
csv_string — содержимое mapping.csv как строка csv_string — содержимое mapping.csv как строка
meta — None при штатном завершении; {"cancelled": True, "processed", "total"}
при прерывании (частичный результат)
""" """
# ── Предобработка: распаковать ZIP + уникализировать имена ── # ── Предобработка: распаковать ZIP + уникализировать имена ──
files = extractor.expand_zips(files) files = extractor.expand_zips(files)
files = _dedupe_file_names(files) files = _dedupe_file_names(files)
# имя файла (внутр. ключ all_texts) -> idx в files (для событий file_progress)
name_to_idx = {fname: i for i, (fname, _, _) in enumerate(files)}
llm_done: set = set() # файлы, чей LLM-анализ ПОЛНОСТЬЮ завершён
def _fp(event, fname, **fields):
if event == "file_done":
llm_done.add(fname)
if file_progress:
fields["idx"] = name_to_idx.get(fname)
file_progress(event, fname, **fields)
try: try:
# ── Проход 1: сбор сущностей ── # ── Проход 1: сбор сущностей ──
# Извлекаем Markdown из каждого файла # Извлекаем Markdown из каждого файла
@@ -142,9 +161,23 @@ class TwoPassObfuscator:
scanner.scan_regex(text, self._mapping, self._counters) scanner.scan_regex(text, self._mapping, self._counters)
file_times[i] += time.time() - t0 file_times[i] += time.time() - t0
# Объём извлечённого текста — для оценки времени (глобальной ETA)
total_chars = sum(len(t) for t in all_texts.values())
if file_progress:
file_progress("extract_done", None, total_chars=total_chars,
per_file={fname: len(t) for fname, t in all_texts.items()})
# LLM-сканирование (получает уже найденное regex'ом чтобы не дублировать) # LLM-сканирование (получает уже найденное regex'ом чтобы не дублировать)
cancelled_llm = False
if self._llm_client: if self._llm_client:
scanner.scan_llm_ner(all_texts, self._mapping, self._llm_client, self._counters) try:
scanner.scan_llm_ner(
all_texts, self._mapping, self._llm_client, self._counters,
cancel_event=cancel_event, file_progress=_fp,
)
except scanner.CancelRequested:
log.info("obfuscate: LLM cancelled, llm_done=%d", len(llm_done))
cancelled_llm = True
# Предсортировать ключи один раз (по убыванию длины) # Предсортировать ключи один раз (по убыванию длины)
self._sorted_keys = sorted( self._sorted_keys = sorted(
@@ -154,6 +187,7 @@ class TwoPassObfuscator:
self._compiled_re = replacer._build_combined_re(self._sorted_keys) self._compiled_re = replacer._build_combined_re(self._sorted_keys)
# ── Проход 2: замена сущностей ── # ── Проход 2: замена сущностей ──
cancelled = cancelled_llm
results: List[Tuple[str, bytes]] = [] results: List[Tuple[str, bytes]] = []
for i, (in_fname, content, ctype) in enumerate(files): for i, (in_fname, content, ctype) in enumerate(files):
@@ -166,6 +200,17 @@ class TwoPassObfuscator:
progress_cb("done", i, total, display_name, 0.0) progress_cb("done", i, total, display_name, 0.0)
continue continue
# При прерывании в LLM — в результат идут ТОЛЬКО файлы с завершённым LLM
if cancelled_llm and fname not in llm_done:
continue
# Мягкая остановка в фазе замены (между файлами).
# НЕ проверяем, если LLM уже прерван: файлы из llm_done добираем (сохраняем всё готовое).
if not cancelled_llm and cancel_event is not None and cancel_event.is_set():
log.info("obfuscate: cancelled in replace phase, processed=%d", len(results))
cancelled = True
break
t0 = time.time() t0 = time.time()
obf_content = content # По умолчанию — без изменений obf_content = content # По умолчанию — без изменений
@@ -198,7 +243,11 @@ class TwoPassObfuscator:
csv_str = builder.build_mapping_csv(self._mapping) csv_str = builder.build_mapping_csv(self._mapping)
zip_data = builder.build_zip(results, csv_str) zip_data = builder.build_zip(results, csv_str)
return zip_data, csv_str meta = None
if cancelled:
expected_total = total - len(skipped)
meta = {"cancelled": True, "processed": len(results), "total": expected_total}
return zip_data, csv_str, meta
finally: finally:
# Очистка состояния (обфускатор может использоваться повторно) # Очистка состояния (обфускатор может использоваться повторно)
@@ -214,8 +263,9 @@ class TwoPassObfuscator:
def obfuscate_files( def obfuscate_files(
files: List[Tuple[str, bytes, str]], llm_client=None, 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], None]] = None,
) -> Tuple[bytes, str]: cancel_event=None, file_progress=None
) -> Tuple[bytes, str, Optional[dict]]:
"""Обфусцировать список файлов — удобная функция. """Обфусцировать список файлов — удобная функция.
Создаёт экземпляр TwoPassObfuscator и вызывает .obfuscate(). Создаёт экземпляр TwoPassObfuscator и вызывает .obfuscate().
@@ -224,9 +274,12 @@ def obfuscate_files(
files: [(filename, content_bytes, content_type), ...] files: [(filename, content_bytes, content_type), ...]
llm_client: Опциональный LLM-клиент llm_client: Опциональный LLM-клиент
progress_cb: Опциональный коллбек (phase, idx, total, fname) progress_cb: Опциональный коллбек (phase, idx, total, fname)
cancel_event: threading.Event — мягкая остановка (см. TwoPassObfuscator.obfuscate)
file_progress: callable(event, fname, **fields) — прогресс по файлам/чанкам
Returns: Returns:
(zip_bytes, csv_string) (zip_bytes, csv_string, meta)
""" """
obf = TwoPassObfuscator(llm_client=llm_client) obf = TwoPassObfuscator(llm_client=llm_client)
return obf.obfuscate(files, progress_cb=progress_cb) return obf.obfuscate(files, progress_cb=progress_cb,
cancel_event=cancel_event, file_progress=file_progress)
+25 -3
View File
@@ -12,6 +12,7 @@
import re import re
import json import json
import random import random
import time
import logging import logging
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from typing import Dict, List, Optional from typing import Dict, List, Optional
@@ -111,6 +112,9 @@ def scan_regex(text: str, mapping: Dict[str, str], counters: Dict[str, int]) ->
# LLM-NER: пофайловый чанкинг (Sonnet-схема) # LLM-NER: пофайловый чанкинг (Sonnet-схема)
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
class CancelRequested(Exception):
"""Обработка прервана пользователем (мягкая остановка между файлами LLM)."""
# Приоритет границ для чанков (от предпочтительных к жёстким) # Приоритет границ для чанков (от предпочтительных к жёстким)
_CHUNK_BOUNDARIES = ['\n\n', '\n', '. ', '? ', '! ', '; ', ', ', ' '] _CHUNK_BOUNDARIES = ['\n\n', '\n', '. ', '? ', '! ', '; ', ', ', ' ']
_CHUNK_SIZE = 6000 # символов на чанк (≈1500-2000 токенов RU — NER-качество) _CHUNK_SIZE = 6000 # символов на чанк (≈1500-2000 токенов RU — NER-качество)
@@ -216,11 +220,12 @@ def _call_llm(text: str, llm_client) -> List[dict]:
def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str], def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str],
llm_client, counters: Dict[str, int]) -> None: llm_client, counters: Dict[str, int],
cancel_event=None, file_progress=None) -> None:
"""Универсальное LLM-обнаружение приватных данных (пофайлово, целиком). """Универсальное LLM-обнаружение приватных данных (пофайлово, целиком).
Каждый файл обрабатывается ПОЛНОСТЬЮ: текст разбивается на чанки Каждый файл обрабатывается ПОЛНОСТЬЮ: текст разбивается на чанки
(_CHUNK_SIZE=6000, overlap=_CHUNK_OVERLAP), чанки обрабатываются параллельно (_CHUNK_SIZE=6000, overlap=_CHUNK_OVERLAP), чанки обрабатываются последовательно
(_LLM_CONCURRENCY), сущности дедуплицируются и верифицируются против полного (_LLM_CONCURRENCY), сущности дедуплицируются и верифицируются против полного
текста файла (отсечка галлюцинаций). regex-найденное не дублируется. текста файла (отсечка галлюцинаций). regex-найденное не дублируется.
@@ -229,6 +234,8 @@ def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str],
mapping: Словарь замен (мутабельный, пополняется) mapping: Словарь замен (мутабельный, пополняется)
llm_client: Объект с методом .complete(prompt) -> str llm_client: Объект с методом .complete(prompt) -> str
counters: Глобальные счётчики токенов (мутабельный) counters: Глобальные счётчики токенов (мутабельный)
cancel_event: threading.Event — мягкая остановка между файлами (текущий добирается до конца)
file_progress: callable(event, fname, **fields) — прогресс по файлам/чанкам
""" """
already_found = list(mapping.keys()) already_found = list(mapping.keys())
@@ -238,10 +245,19 @@ def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str],
) )
for fname, full_text in file_items: for fname, full_text in file_items:
# Мягкая остановка ТОЛЬКО между файлами — текущий файл добирается до конца
if cancel_event is not None and cancel_event.is_set():
raise CancelRequested()
if not full_text or full_text.startswith("[DOC binary"): if not full_text or full_text.startswith("[DOC binary"):
continue continue
chunks = split_into_chunks(full_text) chunks = split_into_chunks(full_text)
if file_progress:
file_progress("file_start", fname, chars=len(full_text), chunks=len(chunks))
t0 = time.time()
# Параллельные вызовы LLM по чанкам этого файла # Параллельные вызовы LLM по чанкам этого файла
if len(chunks) > 1 and _LLM_CONCURRENCY > 1: if len(chunks) > 1 and _LLM_CONCURRENCY > 1:
with ThreadPoolExecutor(max_workers=_LLM_CONCURRENCY) as _ex: with ThreadPoolExecutor(max_workers=_LLM_CONCURRENCY) as _ex:
@@ -252,8 +268,11 @@ def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str],
all_ent.extend(fut.result()) all_ent.extend(fut.result())
else: else:
all_ent = [] all_ent = []
for c in chunks: for k, c in enumerate(chunks):
all_ent.extend(_call_llm(_build_llm_prompt(c, mapping.keys()), llm_client)) all_ent.extend(_call_llm(_build_llm_prompt(c, mapping.keys()), llm_client))
if file_progress:
file_progress("file_chunk", fname,
chunks_done=k + 1, chunks_total=len(chunks))
# Дедуп + верификация + добавление в mapping # Дедуп + верификация + добавление в mapping
seen: set = set() seen: set = set()
@@ -270,3 +289,6 @@ def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str],
continue continue
ent_type = ent.get("type", "").strip().lower().replace(" ", "_") ent_type = ent.get("type", "").strip().lower().replace(" ", "_")
mapping[val] = _next_token(ent_type, counters) mapping[val] = _next_token(ent_type, counters)
if file_progress:
file_progress("file_done", fname, elapsed=round(time.time() - t0, 2))
+1 -1
View File
@@ -21,7 +21,7 @@ if _sys_path_root not in sys.path:
sys.path.insert(0, _sys_path_root) sys.path.insert(0, _sys_path_root)
# Версия приложения (меняется при изменениях) # Версия приложения (меняется при изменениях)
VERSION = "0.0.62" VERSION = "0.0.63"
def setup_logging(): def setup_logging():
+178 -65
View File
@@ -13,6 +13,7 @@ import io
import json import json
import queue import queue
import threading import threading
import time
import zipfile import zipfile
import traceback import traceback
import logging import logging
@@ -23,7 +24,8 @@ from flask import Blueprint, request, send_file, jsonify, Response, stream_with_
from drhider import obfuscate_files, LLMClient from drhider import obfuscate_files, LLMClient
from session import (create_session, add_file, get_files, store_result, from session import (create_session, add_file, get_files, store_result,
get_result, store_csv, get_csv, cleanup, file_count, get_result, store_csv, get_csv, cleanup, file_count,
MAX_FILE_BYTES) MAX_FILE_BYTES, pause_ttl, resume_ttl,
request_cancel, get_cancel_event)
api_bp = Blueprint("api", __name__, url_prefix="/api") api_bp = Blueprint("api", __name__, url_prefix="/api")
log = logging.getLogger("routes.api_bp") log = logging.getLogger("routes.api_bp")
@@ -142,6 +144,20 @@ def session_files(sid):
}) })
@api_bp.route("/cancel/<sid>", methods=["POST"])
def cancel(sid):
"""Запросить мягкое прерывание обработки сессии.
Воркер останавливается на ближайшей границе файла (текущий добирается),
собирает частичный результат (готовые файлы + mapping) и шлёт SSE-событие `cancelled`.
"""
if request_cancel(sid):
log.info("cancel: requested sid=%s", sid)
return jsonify({"ok": True}), 200
log.warning("cancel: session not found sid=%s", sid)
return jsonify({"ok": False, "error": "Session not found"}), 404
@api_bp.route("/process_stream/<sid>", methods=["GET"]) @api_bp.route("/process_stream/<sid>", methods=["GET"])
def process_stream(sid): def process_stream(sid):
"""SSE: process all session files, streaming per-file progress. """SSE: process all session files, streaming per-file progress.
@@ -149,7 +165,7 @@ def process_stream(sid):
Все файлы обрабатываются ЕДИНЫМ вызовом obfuscate_files (общий mapping, Все файлы обрабатываются ЕДИНЫМ вызовом obfuscate_files (общий mapping,
согласованные токены). Обработка идёт в отдельном потоке; прогресс согласованные токены). Обработка идёт в отдельном потоке; прогресс
передаётся через очередь. Разрыв соединения клиента корректно передаётся через очередь. Разрыв соединения клиента корректно
перехватывается и останавливает генератор. перехватывается и останавливает генератор (и воркер — через cancel_event).
""" """
files = get_files(sid) files = get_files(sid)
if files is None: if files is None:
@@ -159,30 +175,60 @@ def process_stream(sid):
all_files = [(fname, content, "") for fname, content in files] all_files = [(fname, content, "") for fname, content in files]
log.info("process_stream: start sid=%s files=%d", sid, len(all_files)) log.info("process_stream: start sid=%s files=%d", sid, len(all_files))
# Сессия живёт, пока идёт обработка (TTL возобновляется в finally генератора)
pause_ttl(sid)
def generate(): def generate():
llm = LLMClient() llm = LLMClient()
q = queue.Queue() q = queue.Queue()
cancel = threading.Event() cancel = threading.Event() # локальный: разрыв клиента (стоп heartbeat)
cancel_event = get_cancel_event(sid) # из сессии: мягкая отмена (кнопка «Прервать»)
# Состояние для глобальной ETA (символы)
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): def progress(phase, idx, total_, name, elapsed):
q.put(("progress", phase, idx, name, total_, elapsed)) q.put(("progress", phase, idx, name, total_, elapsed))
# Состояние для per-file ETA (чанки)
fstate = {"t0": 0.0, "total": 0, "done": 0}
def file_progress(event, fname, **fields):
if event == "file_start":
fstate["t0"] = time.time()
fstate["total"] = fields.get("chunks", 0)
fstate["done"] = 0
elif event == "file_chunk":
fstate["done"] = fields.get("chunks_done", 0)
elapsed = time.time() - fstate["t0"]
rate = fstate["done"] / elapsed if elapsed > 0 else 0
rem = fstate["total"] - fstate["done"]
if rate > 0:
fields["eta_sec"] = max(0, round(rem / rate))
q.put(("file", event, fname, fields))
def worker(): def worker():
log.info("worker: start sid=%s files=%d", sid, len(all_files)) log.info("worker: start sid=%s files=%d", sid, len(all_files))
t0 = datetime.utcnow() t0 = datetime.utcnow()
try: try:
zip_data, csv_str = obfuscate_files( zip_data, csv_str, meta = obfuscate_files(
all_files, llm_client=llm, progress_cb=progress all_files, llm_client=llm, progress_cb=progress,
cancel_event=cancel_event, file_progress=file_progress,
) )
stats = { stats = {
"tokens": llm.tokens_total, "tokens": llm.tokens_total,
"llm_sec": round(llm.llm_sec, 1), "llm_sec": round(llm.llm_sec, 1),
} }
dt = (datetime.utcnow() - t0).total_seconds() dt = (datetime.utcnow() - t0).total_seconds()
log.info("worker: done sid=%s in %.1fs tokens=%d llm_sec=%.1f zip_len=%d", if meta and meta.get("cancelled"):
sid, dt, llm.tokens_total, llm.llm_sec, len(zip_data)) log.info("worker: cancelled sid=%s in %.1fs processed=%d/%d",
q.put(("result", zip_data, csv_str, stats)) sid, dt, meta.get("processed", 0), meta.get("total", 0))
q.put(("cancelled", zip_data, csv_str, stats, meta))
else:
log.info("worker: done sid=%s in %.1fs tokens=%d llm_sec=%.1f zip_len=%d",
sid, dt, llm.tokens_total, llm.llm_sec, len(zip_data))
q.put(("result", zip_data, csv_str, stats))
except Exception as e: except Exception as e:
log.error("worker: exception sid=%s: %r\n%s", sid, e, traceback.format_exc()) log.error("worker: exception sid=%s: %r\n%s", sid, e, traceback.format_exc())
q.put(("error", repr(e))) q.put(("error", repr(e)))
@@ -190,70 +236,133 @@ def process_stream(sid):
threading.Thread(target=worker, daemon=True).start() threading.Thread(target=worker, daemon=True).start()
log.debug("process_stream: worker thread started sid=%s", sid) log.debug("process_stream: worker thread started sid=%s", sid)
while True: try:
try: while True:
evt = q.get(timeout=1)
except queue.Empty:
if cancel.is_set():
log.info("process_stream: cancelled sid=%s (generator exit)", sid)
return
# Heartbeat: живая статистика LLM (для таймера в UI)
try: try:
yield ( evt = q.get(timeout=1)
f"event: llm\n" except queue.Empty:
f"data: {json.dumps({'active': llm.llm_active, 'elapsed': round(llm.llm_elapsed_now(), 1), 'tokens': llm.tokens_total})}\n\n" if cancel.is_set():
) log.info("process_stream: cancelled sid=%s (generator exit)", sid)
except _disconnect_exceptions() as e: return
log.warning("process_stream: disconnect during heartbeat sid=%s err=%r", sid, e) # Heartbeat: живая статистика LLM + глобальная ETA
cancel.set() tokens = llm.tokens_total
return elapsed = llm.llm_elapsed_now()
continue eta_sec = None
if eta["total_chars"] > 0 and tokens > 0 and elapsed > 0:
est_total_tokens = eta["total_chars"] * _TOKENS_PER_CHAR
rate = tokens / elapsed
if rate > 0:
eta_sec = max(0, int((est_total_tokens - tokens) / rate))
done_chars = eta["done_chars"]
if eta["cur_total"] > 0:
done_chars += int(eta["cur_chars"] * (eta["cur_done"] / eta["cur_total"]))
try:
yield (
f"event: llm\n"
f"data: {json.dumps({'active': llm.llm_active, 'elapsed': round(elapsed, 1), 'tokens': tokens, 'eta_sec': eta_sec, 'done_chars': done_chars, 'total_chars': eta['total_chars']})}\n\n"
)
except _disconnect_exceptions() as e:
log.warning("process_stream: disconnect during heartbeat sid=%s err=%r", sid, e)
cancel.set()
if cancel_event:
cancel_event.set()
return
continue
kind = evt[0] kind = evt[0]
if kind == "progress": if kind == "progress":
_, phase, idx, name, total_, elapsed = evt _, phase, idx, name, total_, elapsed = evt
log.debug("process_stream: event=%s idx=%d name=%r elapsed=%s sid=%s", log.debug("process_stream: event=%s idx=%d name=%r elapsed=%s sid=%s",
phase, idx, name, elapsed, sid) phase, idx, name, elapsed, sid)
try: try:
yield ( yield (
f"event: {phase}\n" f"event: {phase}\n"
f"data: {json.dumps({'idx': idx, 'name': name, 'total': total_, 'elapsed': elapsed})}\n\n" f"data: {json.dumps({'idx': idx, 'name': name, 'total': total_, 'elapsed': elapsed})}\n\n"
) )
except _disconnect_exceptions() as e: except _disconnect_exceptions() as e:
log.warning("process_stream: disconnect on progress sid=%s phase=%s err=%r", sid, phase, e) log.warning("process_stream: disconnect on progress sid=%s phase=%s err=%r", sid, phase, e)
cancel.set() cancel.set()
if cancel_event:
cancel_event.set()
return
elif kind == "file":
_, event, fname, fields = evt
# Обновляем состояние для глобальной ETA
if event == "extract_done":
eta["total_chars"] = fields.get("total_chars", 0)
elif event == "file_start":
eta["cur_chars"] = fields.get("chars", 0)
eta["cur_total"] = fields.get("chunks", 0)
eta["cur_done"] = 0
elif event == "file_chunk":
eta["cur_done"] = fields.get("chunks_done", 0)
elif event == "file_done":
eta["done_chars"] += eta["cur_chars"]
eta["cur_chars"] = 0
eta["cur_total"] = 0
eta["cur_done"] = 0
try:
yield (
f"event: {event}\n"
f"data: {json.dumps({'name': fname, **fields})}\n\n"
)
except _disconnect_exceptions() as e:
log.warning("process_stream: disconnect on file event sid=%s err=%r", sid, e)
cancel.set()
if cancel_event:
cancel_event.set()
return
elif kind == "result":
_, zip_data, csv_str, stats = evt
log.info("process_stream: result sid=%s, storing result", sid)
store_result(sid, zip_data)
if csv_str:
store_csv(sid, csv_str)
count = 0
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
count = len([n for n in zf.namelist() if n != "mapping.csv"])
log.info("process_stream: complete sid=%s count=%d stats=%r", sid, count, stats)
try:
yield (
f"event: complete\n"
f"data: {json.dumps({'total': count, **stats})}\n\n"
)
except _disconnect_exceptions() as e:
log.warning("process_stream: disconnect on complete sid=%s err=%r", sid, e)
return
return return
elif kind == "result": elif kind == "cancelled":
_, zip_data, csv_str, stats = evt _, zip_data, csv_str, stats, meta = evt
log.info("process_stream: result sid=%s, storing result", sid) log.info("process_stream: cancelled sid=%s, storing partial result", sid)
store_result(sid, zip_data) store_result(sid, zip_data)
if csv_str: if csv_str:
store_csv(sid, csv_str) store_csv(sid, csv_str)
count = 0 try:
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf: yield (
count = len([n for n in zf.namelist() if n != "mapping.csv"]) f"event: cancelled\n"
log.info("process_stream: complete sid=%s count=%d stats=%r", sid, count, stats) f"data: {json.dumps({'saved': meta.get('processed', 0), 'total': meta.get('total', 0), **stats})}\n\n"
try: )
yield ( except _disconnect_exceptions() as e:
f"event: complete\n" log.warning("process_stream: disconnect on cancelled sid=%s err=%r", sid, e)
f"data: {json.dumps({'total': count, **stats})}\n\n" return
)
except _disconnect_exceptions() as e:
log.warning("process_stream: disconnect on complete sid=%s err=%r", sid, e)
return return
return
elif kind == "error": elif kind == "error":
_, msg = evt _, msg = evt
log.error("process_stream: error event sid=%s msg=%r", sid, msg) log.error("process_stream: error event sid=%s msg=%r", sid, msg)
try: try:
yield f"event: error\ndata: {json.dumps({'error': msg})}\n\n" yield f"event: error\ndata: {json.dumps({'error': msg})}\n\n"
except _disconnect_exceptions() as e: except _disconnect_exceptions() as e:
log.warning("process_stream: disconnect on error sid=%s err=%r", sid, e) log.warning("process_stream: disconnect on error sid=%s err=%r", sid, e)
return
return return
return finally:
resume_ttl(sid)
log.debug("process_stream: generator exit sid=%s, TTL resumed", sid)
return Response( return Response(
stream_with_context(generate()), stream_with_context(generate()),
@@ -273,7 +382,11 @@ def process(sid):
try: try:
llm = LLMClient() llm = LLMClient()
all_files = [(fname, content, "") for fname, content in files] all_files = [(fname, content, "") for fname, content in files]
zip_data, csv_str = obfuscate_files(all_files, llm_client=llm) pause_ttl(sid)
try:
zip_data, csv_str, _ = obfuscate_files(all_files, llm_client=llm)
finally:
resume_ttl(sid)
store_result(sid, zip_data) store_result(sid, zip_data)
if csv_str: if csv_str:
store_csv(sid, csv_str) store_csv(sid, csv_str)
+53
View File
@@ -42,6 +42,58 @@ def _start_timer(sid: str):
return timer return timer
def touch(sid: str):
"""Продлить жизнь сессии: перезапустить TTL-таймер (если сессия существует)."""
with _lock:
s = _sessions.get(sid)
if not s:
return
if s.get("timer"):
s["timer"].cancel()
s["timer"] = _start_timer(sid)
def pause_ttl(sid: str):
"""Приостановить TTL сессии (во время обработки): сессия живёт, пока идёт воркер."""
with _lock:
s = _sessions.get(sid)
if s and s.get("timer"):
s["timer"].cancel()
s["timer"] = None
def resume_ttl(sid: str):
"""Возобновить TTL сессии (после завершения обработки): результат доступен ещё TTL."""
with _lock:
s = _sessions.get(sid)
if not s:
return
if s.get("timer"):
s["timer"].cancel()
s["timer"] = _start_timer(sid)
def request_cancel(sid: str) -> bool:
"""Запросить мягкое прерывание обработки сессии.
Returns:
True если сессия существует и отмена запрошена, False если нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["cancel"].set()
return True
def get_cancel_event(sid: str) -> Optional[threading.Event]:
"""Получить событие отмены сессии (или None, если сессии нет)."""
with _lock:
s = _sessions.get(sid)
return s["cancel"] if s else None
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
# API # API
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
@@ -57,6 +109,7 @@ def create_session() -> str:
_sessions[sid] = { _sessions[sid] = {
"files": [], "files": [],
"result": None, "result": None,
"cancel": threading.Event(),
"timer": _start_timer(sid), "timer": _start_timer(sid),
} }
return sid return sid
+212 -29
View File
@@ -62,6 +62,12 @@
.row-over td { background: rgba(220,38,38,.10) !important; color:#c0392b; } .row-over td { background: rgba(220,38,38,.10) !important; color:#c0392b; }
.row-over .num-cell { color:#c0392b; } .row-over .num-cell { color:#c0392b; }
.row-over .name-cell { color:#c0392b; } .row-over .name-cell { color:#c0392b; }
.row-current td { background: rgba(37,99,235,.12) !important; color:#1d4ed8; font-weight: 600; }
.grp-row td {
background: var(--brand-gray); font-size: 12px; font-weight: 700;
letter-spacing: .03em; text-transform: uppercase; color: var(--muted);
padding: 4px 8px; border: none;
}
.remove-btn { .remove-btn {
cursor: pointer; color: #f87171; background: none; border: none; cursor: pointer; color: #f87171; background: none; border: none;
padding: 2px 4px; font-size: 14px; line-height: 1; padding: 2px 4px; font-size: 14px; line-height: 1;
@@ -115,6 +121,7 @@
font-size: 18px; font-weight: 700; width: fit-content; font-size: 18px; font-weight: 700; width: fit-content;
} }
.live-llm.show { display: inline-block; } .live-llm.show { display: inline-block; }
.live-eta { margin-top: 8px; font-size: 14px; color: #1d4ed8; font-weight: 600; }
</style> </style>
</head> </head>
<body> <body>
@@ -153,6 +160,7 @@
</div> </div>
<div class="footer-bar"> <div class="footer-bar">
<span id="fileCount">0 файлов</span> <span id="fileCount">0 файлов</span>
<button class="btn" id="cancelBtn" style="display:none" onclick="confirmCancel()">⏹ Прервать</button>
<button class="btn btn-primary" id="uploadBtn" disabled onclick="uploadFiles()">🛡️ Обфусцировать</button> <button class="btn btn-primary" id="uploadBtn" disabled onclick="uploadFiles()">🛡️ Обфусцировать</button>
</div> </div>
<div class="status" id="status"></div> <div class="status" id="status"></div>
@@ -165,6 +173,7 @@
<div class="live-file" id="liveFile">—</div> <div class="live-file" id="liveFile">—</div>
<div class="live-timer" id="liveTimer">0.0 с</div> <div class="live-timer" id="liveTimer">0.0 с</div>
<div class="live-llm" id="liveLlm">🤖 ИИ обрабатывает… <span id="liveLlmTime">0.0 с</span></div> <div class="live-llm" id="liveLlm">🤖 ИИ обрабатывает… <span id="liveLlmTime">0.0 с</span></div>
<div class="live-eta" id="liveEta"></div>
</div> </div>
<div class="stats-block" id="statsBlock"> <div class="stats-block" id="statsBlock">
<div class="stats-title">📊 Итоги обработки</div> <div class="stats-title">📊 Итоги обработки</div>
@@ -196,6 +205,13 @@ const db = document.getElementById('dlBtns');
let sf = []; let sf = [];
let fileMeta = new Map(); // имя -> {size, mtime} для дедупа/суффиксов let fileMeta = new Map(); // имя -> {size, mtime} для дедупа/суффиксов
let overNames = new Set(); // имена файлов сверх лимита (не участвуют в обфускации) let overNames = new Set(); // имена файлов сверх лимита (не участвуют в обфускации)
// ── Состояние обработки (3-секционная таблица: готово / текущий / ожидают) ──
let procPhase = 'idle'; // 'idle' | 'processing'
let procState = {}; // sfIdx -> {st, elapsed, eta, est, chars, t0}
let procNameIdx = {}; // имя (с бэка) -> sfIdx
let procExtractDone = false; // true после extract_done (далее done = реальная готовность)
let procRefresh = null; // setInterval перерисовки таблицы
let ptimer = null, liveRefresh = null; // таймеры статуса и live-блока
const MAX_FILE_BYTES = 50 * 1024 * 1024; // 50 МБ на один файл const MAX_FILE_BYTES = 50 * 1024 * 1024; // 50 МБ на один файл
const MAX_SESSION_BYTES = 500 * 1024 * 1024; // 500 МБ суммарно на сессию const MAX_SESSION_BYTES = 500 * 1024 * 1024; // 500 МБ суммарно на сессию
const VM_UPLOAD_URL = 'https://contracts.kube5s.ru/drhider-upload/'; // ВМ-буфер: PUT больших файлов (шлюз кластера их рвёт) const VM_UPLOAD_URL = 'https://contracts.kube5s.ru/drhider-upload/'; // ВМ-буфер: PUT больших файлов (шлюз кластера их рвёт)
@@ -208,10 +224,18 @@ let activeXHR = null; // активный XHR
function resetAll() { function resetAll() {
if (activeES) { activeES.close(); activeES = null; } if (activeES) { activeES.close(); activeES = null; }
if (activeXHR) { activeXHR.abort(); activeXHR = null; } if (activeXHR) { activeXHR.abort(); activeXHR = null; }
if (ptimer) { clearInterval(ptimer); ptimer = null; }
if (liveRefresh) { clearInterval(liveRefresh); liveRefresh = null; }
if (procRefresh) { clearInterval(procRefresh); procRefresh = null; }
currentSid = ''; currentSid = '';
sf = []; sf = [];
fileMeta = new Map(); fileMeta = new Map();
overNames = new Set(); overNames = new Set();
procPhase = 'idle';
procState = {};
procNameIdx = {};
procExtractDone = false;
document.getElementById('cancelBtn').style.display = 'none';
fi.value = ''; fi.value = '';
rr(); rr();
st.className = ''; st.className = '';
@@ -227,7 +251,13 @@ window.addEventListener('beforeunload', () => resetAll());
function fs(b) { return b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(1) + ' KB' : (b / 1048576).toFixed(1) + ' MB'; } function fs(b) { return b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(1) + ' KB' : (b / 1048576).toFixed(1) + ' MB'; }
function fmtSec(s) {
s = Math.max(0, Math.round(s));
return s >= 60 ? Math.floor(s / 60) + 'м ' + (s % 60) + 'с' : s + 'с';
}
function rr() { function rr() {
if (procPhase === 'processing') { renderProcTable(); return; }
if (sf.length === 0) { fl.innerHTML = '<tr class="empty-row"><td colspan="4">Нет выбранных файлов</td></tr>'; } if (sf.length === 0) { fl.innerHTML = '<tr class="empty-row"><td colspan="4">Нет выбранных файлов</td></tr>'; }
else { else {
fl.innerHTML = sf.map((f, i) => { fl.innerHTML = sf.map((f, i) => {
@@ -244,6 +274,91 @@ function rr() {
ub.disabled = cntMain === 0; ub.disabled = cntMain === 0;
} }
// ═══ 3-секционная таблица во время обработки (готово / текущий / ожидают) ═══
function procRow(i, stTxt) {
const f = sf[i];
const over = overNames.has(f.name);
const cls = (procState[i] && procState[i].st === 'current') ? ' class="row-current"'
: (over ? ' class="row-over"' : '');
return '<tr' + cls + '><td class="name-cell">' + f.name + '</td><td class="num-cell">' + fs(f.size) + '</td><td class="num-cell" style="font-size:12px;">' + stTxt + '</td><td></td></tr>';
}
function renderProcTable() {
const groups = { done: [], current: [], pending: [] };
for (let i = 0; i < sf.length; i++) {
const st = procState[i] ? procState[i].st : 'pending';
if (st === 'done' || st === 'skipped') groups.done.push(i);
else if (st === 'current') groups.current.push(i);
else groups.pending.push(i);
}
// Оценка скорости из текущего файла (сек/символ) — для «ожидающих»
let rate = null;
for (const i of groups.current) {
const p = procState[i];
const cur = (performance.now() - p.t0) / 1000;
const total = cur + (p.eta != null ? p.eta : 0);
if (p.chars > 0 && total > 0) rate = total / p.chars;
}
const rows = [];
if (groups.done.length) {
rows.push('<tr class="grp-row"><td colspan="4">✓ Обработанные (' + groups.done.length + ')</td></tr>');
for (const i of groups.done) {
const p = procState[i];
const txt = p.st === 'skipped'
? '<span style="color:#c0392b;">пропущен</span>'
: '<span style="color:#22c55e;">✓ ' + (p.elapsed ? p.elapsed.toFixed(1) : '0.0') + 'с</span>';
rows.push(procRow(i, txt));
}
}
if (groups.current.length) {
rows.push('<tr class="grp-row"><td colspan="4">▶ Текущий файл</td></tr>');
for (const i of groups.current) {
const p = procState[i];
const cur = ((performance.now() - p.t0) / 1000).toFixed(1);
const eta = (p.eta != null) ? ' / ~' + fmtSec(p.eta) : '';
rows.push(procRow(i, '<span style="color:#2563eb;">⏳ ' + cur + 'с' + eta + '</span>'));
}
}
if (groups.pending.length) {
rows.push('<tr class="grp-row"><td colspan="4">○ Ожидают обработки (' + groups.pending.length + ')</td></tr>');
for (const i of groups.pending) {
const p = procState[i] || {};
if (rate && !p.est && p.chars > 0) p.est = p.chars * rate;
let txt;
if (overNames.has(sf[i].name)) txt = '<span style="color:#c0392b;">🔥 не учитывается</span>';
else if (p.st === 'analyzed') txt = '<span style="color:#7d3c98;">анализ ✓</span>';
else if (p.est != null) txt = '<span style="color:#999;">~' + fmtSec(p.est) + '</span>';
else txt = '<span style="color:#999;">—</span>';
rows.push(procRow(i, txt));
}
}
fl.innerHTML = rows.join('');
const cntDone = groups.done.length;
fc.textContent = 'Готово ' + cntDone + ' / ' + sf.length + ' файлов';
}
function confirmCancel() {
document.getElementById('cancelModal').style.display = 'flex';
}
function doCancel() {
document.getElementById('cancelModal').style.display = 'none';
if (!currentSid) return;
fetch('/api/cancel/' + currentSid, { method: 'POST' }).catch(() => {});
st.textContent = '⏹ Остановка… завершаем текущий файл';
}
function finishProcUI() {
if (activeES) { activeES.close(); activeES = null; }
if (ptimer) { clearInterval(ptimer); ptimer = null; }
if (liveRefresh) { clearInterval(liveRefresh); liveRefresh = null; }
if (procRefresh) { clearInterval(procRefresh); procRefresh = null; }
document.getElementById('liveBlock').classList.remove('show');
document.getElementById('cancelBtn').style.display = 'none';
procPhase = 'idle';
rr();
}
function rm(i) { const nm = sf[i].name; overNames.delete(nm); fileMeta.delete(nm); sf.splice(i, 1); const d = new DataTransfer(); sf.forEach(f => d.items.add(f)); fi.files = d.files; rr(); } function rm(i) { const nm = sf[i].name; overNames.delete(nm); fileMeta.delete(nm); sf.splice(i, 1); const d = new DataTransfer(); sf.forEach(f => d.items.add(f)); fi.files = d.files; rr(); }
window.addEventListener('load', () => { sf = []; fileMeta = new Map(); fi.value = ''; rr(); /* прогрев upstream-соединения */ fetch('/health').catch(() => {}); }); window.addEventListener('load', () => { sf = []; fileMeta = new Map(); fi.value = ''; rr(); /* прогрев upstream-соединения */ fetch('/health').catch(() => {}); });
@@ -524,12 +639,24 @@ async function uploadFiles() {
// Фаза 2: обработка (SSE — прогресс по каждому файлу) // Фаза 2: обработка (SSE — прогресс по каждому файлу)
// Сброс загрузочных статусов — теперь этап обработки (только отправленные) // Сброс загрузочных статусов — теперь этап обработки (только отправленные)
for (let k = 0; k < total; k++) ss(sendIdx[k], '<span style="color:#2563eb">⏳</span>'); for (let k = 0; k < total; k++) ss(sendIdx[k], '<span style="color:#2563eb">⏳</span>');
// Инициализация 3-секционной таблицы (готово / текущий / ожидают)
procPhase = 'processing';
procState = {};
procNameIdx = {};
procExtractDone = false;
for (let k = 0; k < total; k++) {
procState[sendIdx[k]] = { st: 'pending', elapsed: 0, eta: null, est: null, chars: 0, t0: 0 };
}
document.getElementById('cancelBtn').style.display = 'inline-block';
if (procRefresh) clearInterval(procRefresh);
procRefresh = setInterval(rr, 1000); // тикающий рендер (elapsed текущего файла)
rr();
const fileTimers = {}; // idx -> performance.now() const fileTimers = {}; // idx -> performance.now()
const fileIntervals = {}; // idx -> setInterval id const fileIntervals = {}; // idx -> setInterval id
st.className = 'status progress'; st.className = 'status progress';
st.textContent = 'Обработка (этап 2/2)…'; st.textContent = 'Обработка (этап 2/2)…';
const t0 = performance.now(); const t0 = performance.now();
const ptimer = setInterval(() => { ptimer = setInterval(() => {
const sec = Math.round((performance.now() - t0) / 1000); const sec = Math.round((performance.now() - t0) / 1000);
st.textContent = 'Обработка (этап 2/2)… ' + sec + 'с'; st.textContent = 'Обработка (этап 2/2)… ' + sec + 'с';
}, 1000); }, 1000);
@@ -540,12 +667,14 @@ async function uploadFiles() {
const liveFileEl = document.getElementById('liveFile'); const liveFileEl = document.getElementById('liveFile');
const liveLlm = document.getElementById('liveLlm'); const liveLlm = document.getElementById('liveLlm');
const liveLlmTime = document.getElementById('liveLlmTime'); const liveLlmTime = document.getElementById('liveLlmTime');
const liveEta = document.getElementById('liveEta');
let currentLiveFile = ''; // последнее имя файла из start let currentLiveFile = ''; // последнее имя файла из start
liveLlm.classList.remove('show'); liveLlm.classList.remove('show');
liveEta.textContent = '';
liveTimerEl.textContent = '0.0 с'; liveTimerEl.textContent = '0.0 с';
liveFileEl.textContent = 'Подготовка…'; liveFileEl.textContent = 'Подготовка…';
liveBlock.classList.add('show'); liveBlock.classList.add('show');
const liveRefresh = setInterval(() => { liveRefresh = setInterval(() => {
const sec = ((performance.now() - t0) / 1000).toFixed(1); const sec = ((performance.now() - t0) / 1000).toFixed(1);
liveTimerEl.textContent = sec + ' с'; liveTimerEl.textContent = sec + ' с';
}, 200); }, 200);
@@ -556,29 +685,53 @@ async function uploadFiles() {
activeES.addEventListener('start', function(e) { activeES.addEventListener('start', function(e) {
const d = JSON.parse(e.data); const d = JSON.parse(e.data);
const idx = sendIdx[d.idx]; // индекс в sf для вывода прогресса const idx = sendIdx[d.idx]; // индекс в sf для вывода прогресса
// Таймер тикает ТОЛЬКО у текущего файла; таймеры остальных останавливаем procNameIdx[d.name] = idx;
for (const k in fileIntervals) { const p = procState[idx];
clearInterval(fileIntervals[k]); if (p) { p.st = 'pending'; p.elapsed = 0; }
delete fileIntervals[k];
}
fileTimers[idx] = performance.now();
fileIntervals[idx] = setInterval(function() {
const elapsed = ((performance.now() - fileTimers[idx]) / 1000).toFixed(1);
ss(idx, '<span style="color:#2563eb">⏳ ' + elapsed + 'с</span>');
}, 200);
// Показываем текущий файл и его размер в live-блоке
const f = sf[idx]; const f = sf[idx];
const sz = f ? fs(f.size) : ''; const sz = f ? fs(f.size) : '';
currentLiveFile = 'Файл ' + (d.idx + 1) + '/' + total + ': ' + d.name + (sz ? ' (' + sz + ')' : ''); currentLiveFile = 'Файл ' + (d.idx + 1) + '/' + total + ': ' + d.name + (sz ? ' (' + sz + ')' : '');
liveFileEl.textContent = currentLiveFile; liveFileEl.textContent = currentLiveFile;
}); });
activeES.addEventListener('extract_done', function(e) {
const d = JSON.parse(e.data);
procExtractDone = true;
// per-file символы -> оценка времени для ожидающих файлов
if (d.per_file) {
for (const nm in d.per_file) {
const idx = procNameIdx[nm];
if (idx != null && procState[idx]) procState[idx].chars = d.per_file[nm];
}
}
});
activeES.addEventListener('file_start', function(e) {
const d = JSON.parse(e.data);
const idx = sendIdx[d.idx];
const p = procState[idx];
if (p) { p.st = 'current'; p.t0 = performance.now(); p.eta = null; }
});
activeES.addEventListener('file_chunk', function(e) {
const d = JSON.parse(e.data);
const idx = sendIdx[d.idx];
const p = procState[idx];
if (p && typeof d.eta_sec === 'number') p.eta = d.eta_sec;
liveFileEl.textContent = 'Файл ' + (d.idx + 1) + '/' + total + ': ' + d.name +
(typeof d.eta_sec === 'number' ? ' — осталось ~' + fmtSec(d.eta_sec) : '');
});
activeES.addEventListener('file_done', function(e) {
const d = JSON.parse(e.data);
const idx = sendIdx[d.idx];
const p = procState[idx];
if (p) { p.st = 'analyzed'; }
});
activeES.addEventListener('llm', function(e) { activeES.addEventListener('llm', function(e) {
const d = JSON.parse(e.data); const d = JSON.parse(e.data);
if (d.active) { if (d.active) {
liveLlmTime.textContent = d.elapsed + ' с'; liveLlmTime.textContent = d.elapsed + ' с';
liveLlm.classList.add('show'); liveLlm.classList.add('show');
// LLM анализирует ВСЕ файлы вместе — не показываем имя конкретного
liveFileEl.textContent = '🤖 ИИ анализирует все файлы…'; liveFileEl.textContent = '🤖 ИИ анализирует все файлы…';
if (typeof d.eta_sec === 'number' && d.eta_sec >= 0) liveEta.textContent = '⏳ осталось ~' + fmtSec(d.eta_sec);
else liveEta.textContent = '';
} else { } else {
liveLlm.classList.remove('show'); liveLlm.classList.remove('show');
if (currentLiveFile) liveFileEl.textContent = currentLiveFile; if (currentLiveFile) liveFileEl.textContent = currentLiveFile;
@@ -587,19 +740,41 @@ async function uploadFiles() {
activeES.addEventListener('done', function(e) { activeES.addEventListener('done', function(e) {
const d = JSON.parse(e.data); const d = JSON.parse(e.data);
const idx = sendIdx[d.idx]; const idx = sendIdx[d.idx];
clearInterval(fileIntervals[idx]); if (!procExtractDone) {
// done на этапе извлечения = битый/пропущенный файл
const p0 = procState[idx];
if (p0) p0.st = 'skipped';
return;
}
// Время на КОНКРЕТНЫЙ файл приходит с бэка (extract_text + замена, без общего LLM) // Время на КОНКРЕТНЫЙ файл приходит с бэка (extract_text + замена, без общего LLM)
const sec = (typeof d.elapsed === 'number' && d.elapsed > 0) const sec = (typeof d.elapsed === 'number' && d.elapsed > 0)
? d.elapsed.toFixed(1) ? d.elapsed.toFixed(1)
: ((performance.now() - (fileTimers[idx] || performance.now())) / 1000).toFixed(1); : ((performance.now() - (fileTimers[idx] || performance.now())) / 1000).toFixed(1);
ss(idx, '<span style="color:#22c55e">✓ ' + sec + 'с</span>'); const p = procState[idx];
if (p) { p.st = 'done'; p.elapsed = d.elapsed > 0 ? d.elapsed : parseFloat(sec); }
else procState[idx] = { st: 'done', elapsed: parseFloat(sec), eta: null, est: null, chars: 0, t0: 0 };
});
activeES.addEventListener('cancelled', function(e) {
const d = JSON.parse(e.data);
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');
db.classList.add('show');
resolve();
}); });
activeES.addEventListener('complete', function(e) { activeES.addEventListener('complete', function(e) {
activeES.close(); finishProcUI();
activeES = null;
clearInterval(ptimer);
clearInterval(liveRefresh);
liveBlock.classList.remove('show');
const d = JSON.parse(e.data); const d = JSON.parse(e.data);
const totalSec = ((performance.now() - t0) / 1000).toFixed(1); const totalSec = ((performance.now() - t0) / 1000).toFixed(1);
st.className = 'status done'; st.className = 'status done';
@@ -619,18 +794,12 @@ async function uploadFiles() {
resolve(); resolve();
}); });
activeES.onerror = function() { activeES.onerror = function() {
activeES.close(); finishProcUI();
activeES = null;
clearInterval(ptimer);
clearInterval(liveRefresh);
liveBlock.classList.remove('show');
reject(new Error('SSE connection failed')); reject(new Error('SSE connection failed'));
}; };
}); });
} catch (err) { } catch (err) {
clearInterval(ptimer); finishProcUI();
clearInterval(liveRefresh);
liveBlock.classList.remove('show');
st.className = 'status error'; st.className = 'status error';
st.textContent = 'Ошибка: ' + err.message; st.textContent = 'Ошибка: ' + err.message;
} }
@@ -686,5 +855,19 @@ function downloadCsv() {
<button class="btn" style="margin-top:12px;width:100%" onclick="document.getElementById('helpModal').style.display='none'">Закрыть</button> <button class="btn" style="margin-top:12px;width:100%" onclick="document.getElementById('helpModal').style.display='none'">Закрыть</button>
</div> </div>
</div> </div>
<div id="cancelModal" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5);z-index:999;justify-content:center;align-items:center" onclick="this.style.display='none'">
<div style="background:var(--card);border-radius:12px;padding:24px;max-width:440px;box-shadow:0 4px 24px rgba(0,0,0,.15)" onclick="event.stopPropagation()">
<h3 style="margin-bottom:12px">⏹ Остановить обработку?</h3>
<p style="font-size:13px;color:var(--muted);line-height:1.6" id="cancelModalText">
Будут сохранены: полностью обработанные файлы и таблица замен.
Необработанные файлы в результат не попадут.
Текущий анализ будет доведён до конца.
</p>
<div style="margin-top:16px;display:flex;gap:8px;justify-content:flex-end">
<button class="btn" onclick="document.getElementById('cancelModal').style.display='none'">Отмена</button>
<button class="btn btn-primary" onclick="doCancel()">Остановить</button>
</div>
</div>
</div>
</body> </body>
</html> </html>