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:
+39
-21
@@ -32,7 +32,7 @@ class LLMIncompleteError(Exception):
|
|||||||
|
|
||||||
def _dedupe_file_names(
|
def _dedupe_file_names(
|
||||||
files: List[Tuple[str, bytes, str]]
|
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), ...]
|
files: [(filename, content_bytes, content_type), ...]
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Новый список с уникальными именами (порядок сохранён).
|
Новый список с уникальными именами (порядок сохранён) и исходные индексы,
|
||||||
|
соответствующие каждому файлу. Точные дубликаты ссылаются на один результат.
|
||||||
"""
|
"""
|
||||||
seen: Dict[str, bytes] = {}
|
seen: Dict[str, bytes] = {}
|
||||||
out: List[Tuple[str, bytes, str]] = []
|
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)
|
candidate = resolve_name(fname, content, seen)
|
||||||
if candidate is not None:
|
if candidate is not None:
|
||||||
out.append((candidate, content, ctype))
|
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:
|
class TwoPassObfuscator:
|
||||||
@@ -89,7 +96,7 @@ 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, float, List[int]], None]] = None,
|
||||||
cancel_event=None, file_progress=None
|
cancel_event=None, file_progress=None
|
||||||
) -> Tuple[bytes, str, Optional[dict]]:
|
) -> Tuple[bytes, str, Optional[dict]]:
|
||||||
"""Обфусцировать список файлов.
|
"""Обфусцировать список файлов.
|
||||||
@@ -98,14 +105,18 @@ class TwoPassObfuscator:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
files: [(filename, content_bytes, content_type), ...]
|
files: [(filename, content_bytes, content_type), ...]
|
||||||
progress_cb: Опциональный коллбек (phase, idx, total, fname, elapsed),
|
progress_cb: Опциональный коллбек (phase, idx, total, fname, elapsed, source_idxs),
|
||||||
где phase ∈ {"start", "done"}, idx — 0-based индекс,
|
phase — extract_start/extract_done/extract_failed или
|
||||||
elapsed — время обработки конкретного файла (сек).
|
replace_start/replace_done/replace_failed; idx — 0-based индекс,
|
||||||
|
elapsed — время обработки конкретного файла (сек),
|
||||||
|
source_idxs — индексы строк исходного списка, включая дубликаты.
|
||||||
cancel_event: threading.Event — мягкая остановка. Проверяется между
|
cancel_event: threading.Event — мягкая остановка. Проверяется между
|
||||||
файлами LLM и между файлами замены; текущий файл добирается.
|
файлами LLM и между файлами замены; текущий файл добирается.
|
||||||
file_progress: callable(event, fname, **fields) — прогресс по файлам/чанкам.
|
file_progress: callable(event, fname, **fields) — прогресс по файлам/чанкам.
|
||||||
События: extract_done{total_chars}, file_start{chars,chunks,idx},
|
События: extract_complete{total_chars},
|
||||||
file_chunk{chunks_done,chunks_total,idx}, file_done{elapsed,idx}.
|
file_start{chars,chunks,idx,source_idxs},
|
||||||
|
file_chunk{chunks_done,chunks_total,idx,source_idxs},
|
||||||
|
file_done{elapsed,idx,source_idxs}.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(zip_bytes, csv_string, meta):
|
(zip_bytes, csv_string, meta):
|
||||||
@@ -116,7 +127,7 @@ class TwoPassObfuscator:
|
|||||||
"""
|
"""
|
||||||
# ── Предобработка: распаковать ZIP + уникализировать имена ──
|
# ── Предобработка: распаковать ZIP + уникализировать имена ──
|
||||||
files = extractor.expand_zips(files)
|
files = extractor.expand_zips(files)
|
||||||
files = _dedupe_file_names(files)
|
files, source_indices = _dedupe_file_names(files)
|
||||||
|
|
||||||
# имя файла (внутр. ключ all_texts) -> idx в files (для событий file_progress)
|
# имя файла (внутр. ключ all_texts) -> idx в files (для событий file_progress)
|
||||||
name_to_idx = {fname: i for i, (fname, _, _) in enumerate(files)}
|
name_to_idx = {fname: i for i, (fname, _, _) in enumerate(files)}
|
||||||
@@ -126,7 +137,9 @@ class TwoPassObfuscator:
|
|||||||
if event == "file_done":
|
if event == "file_done":
|
||||||
llm_done.add(fname)
|
llm_done.add(fname)
|
||||||
if file_progress:
|
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)
|
file_progress(event, fname, **fields)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -150,7 +163,7 @@ class TwoPassObfuscator:
|
|||||||
break
|
break
|
||||||
display_name = os.path.basename(fname) or fname
|
display_name = os.path.basename(fname) or fname
|
||||||
if progress_cb:
|
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()
|
t0 = time.time()
|
||||||
try:
|
try:
|
||||||
@@ -159,9 +172,11 @@ class TwoPassObfuscator:
|
|||||||
log.warning("obfuscate: skip file %r: %r", fname, e)
|
log.warning("obfuscate: skip file %r: %r", fname, e)
|
||||||
skipped.add(fname)
|
skipped.add(fname)
|
||||||
if progress_cb:
|
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
|
continue
|
||||||
all_texts[fname] = text
|
all_texts[fname] = text
|
||||||
|
if progress_cb:
|
||||||
|
progress_cb("extract_done", i, total, display_name, 0.0, source_indices[i])
|
||||||
|
|
||||||
# Regex-сканирование (быстрое, локальное)
|
# Regex-сканирование (быстрое, локальное)
|
||||||
if text and not text.startswith("[DOC binary"):
|
if text and not text.startswith("[DOC binary"):
|
||||||
@@ -171,7 +186,7 @@ class TwoPassObfuscator:
|
|||||||
# Объём извлечённого текста — для оценки времени (глобальной ETA)
|
# Объём извлечённого текста — для оценки времени (глобальной ETA)
|
||||||
total_chars = sum(len(t) for t in all_texts.values())
|
total_chars = sum(len(t) for t in all_texts.values())
|
||||||
if file_progress:
|
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()})
|
per_file={fname: len(t) for fname, t in all_texts.items()})
|
||||||
|
|
||||||
# LLM-сканирование (получает уже найденное regex'ом чтобы не дублировать)
|
# LLM-сканирование (получает уже найденное regex'ом чтобы не дублировать)
|
||||||
@@ -208,8 +223,6 @@ class TwoPassObfuscator:
|
|||||||
|
|
||||||
# Битый файл (не удалось извлечь текст) — пропускаем
|
# Битый файл (не удалось извлечь текст) — пропускаем
|
||||||
if fname in skipped:
|
if fname in skipped:
|
||||||
if progress_cb:
|
|
||||||
progress_cb("done", i, total, display_name, 0.0)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# При прерывании в LLM — в результат идут ТОЛЬКО файлы с завершённым LLM
|
# При прерывании в LLM — в результат идут ТОЛЬКО файлы с завершённым LLM
|
||||||
@@ -223,6 +236,8 @@ class TwoPassObfuscator:
|
|||||||
cancelled = True
|
cancelled = True
|
||||||
break
|
break
|
||||||
|
|
||||||
|
if progress_cb:
|
||||||
|
progress_cb("replace_start", i, total, display_name, 0.0, source_indices[i])
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
obf_content = content # По умолчанию — без изменений
|
obf_content = content # По умолчанию — без изменений
|
||||||
|
|
||||||
@@ -235,7 +250,7 @@ class TwoPassObfuscator:
|
|||||||
log.warning("obfuscate: skip CSV %r during replacement: %r", fname, e)
|
log.warning("obfuscate: skip CSV %r during replacement: %r", fname, e)
|
||||||
skipped.add(fname)
|
skipped.add(fname)
|
||||||
if progress_cb:
|
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
|
continue
|
||||||
elif fname.endswith('.doc'):
|
elif fname.endswith('.doc'):
|
||||||
# .doc — конвертируется через liberta, обрабатывается как .docx
|
# .doc — конвертируется через liberta, обрабатывается как .docx
|
||||||
@@ -260,7 +275,10 @@ class TwoPassObfuscator:
|
|||||||
file_times[i] += time.time() - t0
|
file_times[i] += time.time() - t0
|
||||||
results.append((fname, obf_content))
|
results.append((fname, obf_content))
|
||||||
if progress_cb:
|
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)
|
csv_str = builder.build_mapping_csv(self._mapping)
|
||||||
@@ -286,7 +304,7 @@ 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, float, List[int]], None]] = None,
|
||||||
cancel_event=None, file_progress=None
|
cancel_event=None, file_progress=None
|
||||||
) -> Tuple[bytes, str, Optional[dict]]:
|
) -> Tuple[bytes, str, Optional[dict]]:
|
||||||
"""Обфусцировать список файлов — удобная функция.
|
"""Обфусцировать список файлов — удобная функция.
|
||||||
@@ -296,7 +314,7 @@ def obfuscate_files(
|
|||||||
Args:
|
Args:
|
||||||
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, elapsed, source_idxs)
|
||||||
cancel_event: threading.Event — мягкая остановка (см. TwoPassObfuscator.obfuscate)
|
cancel_event: threading.Event — мягкая остановка (см. TwoPassObfuscator.obfuscate)
|
||||||
file_progress: callable(event, fname, **fields) — прогресс по файлам/чанкам
|
file_progress: callable(event, fname, **fields) — прогресс по файлам/чанкам
|
||||||
|
|
||||||
|
|||||||
+13
-4
@@ -14,7 +14,7 @@ import json
|
|||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from .config import (
|
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:
|
with ThreadPoolExecutor(max_workers=_LLM_CONCURRENCY) as _ex:
|
||||||
futures = [_ex.submit(_call_llm, _build_llm_prompt(c, mapping.keys()), llm_client)
|
futures = [_ex.submit(_call_llm, _build_llm_prompt(c, mapping.keys()), llm_client)
|
||||||
for c in chunks]
|
for c in chunks]
|
||||||
all_ent = []
|
chunk_results = [None] * len(futures)
|
||||||
for fut in futures:
|
future_indexes = {future: index for index, future in enumerate(futures)}
|
||||||
|
completed = 0
|
||||||
|
for fut in as_completed(futures):
|
||||||
|
index = future_indexes[fut]
|
||||||
try:
|
try:
|
||||||
all_ent.extend(fut.result())
|
chunk_results[index] = fut.result()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
llm_failures += 1
|
llm_failures += 1
|
||||||
log.warning("LLM NER chunk failed in %r: %s", fname, e)
|
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:
|
else:
|
||||||
all_ent = []
|
all_ent = []
|
||||||
for k, c in enumerate(chunks):
|
for k, c in enumerate(chunks):
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ if _sys_path_root not in sys.path:
|
|||||||
sys.path.insert(0, _sys_path_root)
|
sys.path.insert(0, _sys_path_root)
|
||||||
|
|
||||||
# Версия приложения (меняется при изменениях)
|
# Версия приложения (меняется при изменениях)
|
||||||
VERSION = "1.1.4"
|
VERSION = "1.1.5"
|
||||||
|
|
||||||
|
|
||||||
def setup_logging():
|
def setup_logging():
|
||||||
|
|||||||
@@ -150,8 +150,8 @@ def process_stream(sid):
|
|||||||
eta = {"total_chars": 0, "done_chars": 0, "cur_chars": 0, "cur_total": 0, "cur_done": 0}
|
eta = {"total_chars": 0, "done_chars": 0, "cur_chars": 0, "cur_total": 0, "cur_done": 0}
|
||||||
_TOKENS_PER_CHAR = 8.0 # эмпирический коэффициент символов -> токенов LLM
|
_TOKENS_PER_CHAR = 8.0 # эмпирический коэффициент символов -> токенов LLM
|
||||||
|
|
||||||
def progress(phase, idx, total_, name, elapsed):
|
def progress(phase, idx, total_, name, elapsed, source_idxs):
|
||||||
q.put(("progress", phase, idx, name, total_, elapsed))
|
q.put(("progress", phase, idx, name, total_, elapsed, source_idxs))
|
||||||
|
|
||||||
# Состояние для per-file ETA (чанки)
|
# Состояние для per-file ETA (чанки)
|
||||||
fstate = {"t0": 0.0, "total": 0, "done": 0}
|
fstate = {"t0": 0.0, "total": 0, "done": 0}
|
||||||
@@ -241,13 +241,13 @@ def process_stream(sid):
|
|||||||
kind = evt[0]
|
kind = evt[0]
|
||||||
|
|
||||||
if kind == "progress":
|
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",
|
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, 'source_idxs': source_idxs, '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)
|
||||||
@@ -259,7 +259,7 @@ def process_stream(sid):
|
|||||||
elif kind == "file":
|
elif kind == "file":
|
||||||
_, event, fname, fields = evt
|
_, event, fname, fields = evt
|
||||||
# Обновляем состояние для глобальной ETA
|
# Обновляем состояние для глобальной ETA
|
||||||
if event == "extract_done":
|
if event == "extract_complete":
|
||||||
eta["total_chars"] = fields.get("total_chars", 0)
|
eta["total_chars"] = fields.get("total_chars", 0)
|
||||||
elif event == "file_start":
|
elif event == "file_start":
|
||||||
eta["cur_chars"] = fields.get("chars", 0)
|
eta["cur_chars"] = fields.get("chars", 0)
|
||||||
|
|||||||
Vendored
+4
-3
@@ -641,14 +641,15 @@ function renderNode(node2, depth, cfg) {
|
|||||||
const name = esc(node2.name);
|
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 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 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 = {
|
const cells = {
|
||||||
path: action,
|
path: action,
|
||||||
size: node2.kind === "file" ? fs(node2.file.size) : "",
|
size: node2.kind === "file" ? fs(node2.file.size) : "",
|
||||||
status: node2.error ? esc(node2.error) : node2.kind === "file" ? esc(cfg.labels.statusReady) : ""
|
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 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;
|
if (!isGroup || !node2.expanded) return row;
|
||||||
return row + node2.children.map((child) => renderNode(child, depth + 1, cfg)).join("");
|
return row + node2.children.map((child) => renderNode(child, depth + 1, cfg)).join("");
|
||||||
}
|
}
|
||||||
@@ -675,7 +676,7 @@ function findNode(nodes, id) {
|
|||||||
}
|
}
|
||||||
function flattenFiles(nodes, result = []) {
|
function flattenFiles(nodes, result = []) {
|
||||||
nodes.forEach((node2) => {
|
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);
|
else flattenFiles(node2.children, result);
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
Vendored
+4
-3
@@ -669,14 +669,15 @@ var FilePicker = (() => {
|
|||||||
const name = esc(node2.name);
|
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 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 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 = {
|
const cells = {
|
||||||
path: action,
|
path: action,
|
||||||
size: node2.kind === "file" ? fs(node2.file.size) : "",
|
size: node2.kind === "file" ? fs(node2.file.size) : "",
|
||||||
status: node2.error ? esc(node2.error) : node2.kind === "file" ? esc(cfg.labels.statusReady) : ""
|
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 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;
|
if (!isGroup || !node2.expanded) return row;
|
||||||
return row + node2.children.map((child) => renderNode(child, depth + 1, cfg)).join("");
|
return row + node2.children.map((child) => renderNode(child, depth + 1, cfg)).join("");
|
||||||
}
|
}
|
||||||
@@ -703,7 +704,7 @@ var FilePicker = (() => {
|
|||||||
}
|
}
|
||||||
function flattenFiles(nodes, result = []) {
|
function flattenFiles(nodes, result = []) {
|
||||||
nodes.forEach((node2) => {
|
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);
|
else flattenFiles(node2.children, result);
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
+288
-97
@@ -375,6 +375,12 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status.cancelled {
|
||||||
|
background: #fff4e5;
|
||||||
|
color: #825500;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.dl-btns {
|
.dl-btns {
|
||||||
display: none;
|
display: none;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -543,6 +549,9 @@
|
|||||||
let busy = false;
|
let busy = false;
|
||||||
let sessionDone = false;
|
let sessionDone = false;
|
||||||
let ptimer = null, liveRefresh = null;
|
let ptimer = null, liveRefresh = null;
|
||||||
|
let tableFiles = [];
|
||||||
|
const fileStatuses = new Map();
|
||||||
|
const groupMembers = new Map();
|
||||||
|
|
||||||
const picker = FilePicker.initFilePicker({
|
const picker = FilePicker.initFilePicker({
|
||||||
mount: '#filePicker',
|
mount: '#filePicker',
|
||||||
@@ -552,7 +561,7 @@
|
|||||||
pickFolder: 'Выбрать папку',
|
pickFolder: 'Выбрать папку',
|
||||||
clear: 'Очистить',
|
clear: 'Очистить',
|
||||||
empty: 'Нет выбранных файлов',
|
empty: 'Нет выбранных файлов',
|
||||||
statusReady: 'готов',
|
statusReady: 'Выбран',
|
||||||
remove: '✕',
|
remove: '✕',
|
||||||
columns: { path: 'Имя', size: 'Размер', status: 'Статус' },
|
columns: { path: 'Имя', size: 'Размер', status: 'Статус' },
|
||||||
},
|
},
|
||||||
@@ -563,11 +572,123 @@
|
|||||||
maxDepth: 20,
|
maxDepth: 20,
|
||||||
},
|
},
|
||||||
onChange: (files) => {
|
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;
|
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) {
|
function setBusy(b) {
|
||||||
busy = b;
|
busy = b;
|
||||||
document.body.classList.toggle('busy', b);
|
document.body.classList.toggle('busy', b);
|
||||||
@@ -578,6 +699,15 @@
|
|||||||
return s >= 60 ? Math.floor(s / 60) + 'м ' + (s % 60) + 'с' : s + 'с';
|
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() {
|
function confirmCancel() {
|
||||||
document.getElementById('cancelModal').style.display = 'flex';
|
document.getElementById('cancelModal').style.display = 'flex';
|
||||||
}
|
}
|
||||||
@@ -653,17 +783,27 @@
|
|||||||
signal: abortCtrl.signal,
|
signal: abortCtrl.signal,
|
||||||
onStatus: (msg) => {
|
onStatus: (msg) => {
|
||||||
st.className = 'status progress';
|
st.className = 'status progress';
|
||||||
st.textContent = msg;
|
st.textContent = msg
|
||||||
|
.replace('Приём сервисом', 'Сохранение в сессию')
|
||||||
|
.replace('Завершено. Успешно передано файлов:', 'Загрузка завершена. Передано файлов:');
|
||||||
},
|
},
|
||||||
onFileStatus: (k, statusText) => {
|
onFileStatus: (k, statusText) => {
|
||||||
const rows = document.querySelectorAll('.tree-row.tree-file');
|
const text = statusText
|
||||||
if (rows[k]) {
|
.replace(/^⏳ В буфер\.\.\./, 'Загрузка в буфер')
|
||||||
const td = rows[k].querySelectorAll('td')[2];
|
.replace(/^⏳ (\d+%.*)$/, 'Загрузка в буфер · $1')
|
||||||
if (td) td.innerHTML = statusText;
|
.replace(/^⚡ В сессию\.\.\./, 'Сохранение в сессию')
|
||||||
}
|
.replace(/^✓ Доставлен$/, '✓ Загружен');
|
||||||
|
const outcome = /ошибка/i.test(text) ? 'error'
|
||||||
|
: text === '✓ Загружен' ? 'uploaded' : 'progress';
|
||||||
|
setFileStatus(k, text, outcome, outcome === 'error' || outcome === 'uploaded');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
tableFiles.forEach((file, index) => {
|
||||||
|
if (!fileStatuses.get(file.id)?.terminal) {
|
||||||
|
setFileStatus(index, 'Не загружен: ошибка передачи', 'error', true);
|
||||||
|
}
|
||||||
|
});
|
||||||
st.className = 'status error';
|
st.className = 'status error';
|
||||||
st.textContent = 'Ошибка загрузки: ' + err.message;
|
st.textContent = 'Ошибка загрузки: ' + err.message;
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
@@ -674,9 +814,19 @@
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (res.aborted) {
|
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 = 'Загрузка отменена пользователем.';
|
st.textContent = 'Загрузка отменена пользователем.';
|
||||||
} else {
|
} else {
|
||||||
|
tableFiles.forEach((_, index) => {
|
||||||
|
if (!fileStatuses.get(tableFiles[index].id)?.terminal) {
|
||||||
|
setFileStatus(index, 'Не загружен: передача прервана', 'error', true);
|
||||||
|
}
|
||||||
|
});
|
||||||
st.className = 'status error';
|
st.className = 'status error';
|
||||||
st.textContent = res.error || 'Ошибка загрузки';
|
st.textContent = res.error || 'Ошибка загрузки';
|
||||||
}
|
}
|
||||||
@@ -691,7 +841,7 @@
|
|||||||
|
|
||||||
if (TEST_UPLOAD_ONLY) {
|
if (TEST_UPLOAD_ONLY) {
|
||||||
st.className = 'status done';
|
st.className = 'status done';
|
||||||
st.textContent = 'Загружено ' + uploadedCount + ' файлов (тест: обработка пропущена). Сессия: ' + currentSid;
|
st.textContent = 'Загружено ' + formatFileCount(uploadedCount) + ' (тест: обработка пропущена). Сессия: ' + currentSid;
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
cb.style.display = 'none';
|
cb.style.display = 'none';
|
||||||
ub.disabled = false;
|
ub.disabled = false;
|
||||||
@@ -704,14 +854,18 @@
|
|||||||
|
|
||||||
async function startProcessingPhase(total, files) {
|
async function startProcessingPhase(total, files) {
|
||||||
st.className = 'status progress';
|
st.className = 'status progress';
|
||||||
st.textContent = 'Обработка (этап 2/2)…';
|
st.textContent = 'Обработка: подготовка';
|
||||||
cb.style.display = 'inline-block';
|
cb.style.display = 'inline-block';
|
||||||
|
|
||||||
const rows = document.querySelectorAll('.tree-row.tree-file');
|
fileStatuses.clear();
|
||||||
rows.forEach(r => {
|
files.forEach((file) => {
|
||||||
const td = r.querySelectorAll('td')[2];
|
fileStatuses.set(file.id, {
|
||||||
if (td) td.innerHTML = '<span style="color:#2563eb">⏳</span>';
|
text: 'В очереди',
|
||||||
|
outcome: 'queued',
|
||||||
|
terminal: false
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
renderFileStatuses();
|
||||||
|
|
||||||
const liveBlock = document.getElementById('liveBlock');
|
const liveBlock = document.getElementById('liveBlock');
|
||||||
const liveTimerEl = document.getElementById('liveTimer');
|
const liveTimerEl = document.getElementById('liveTimer');
|
||||||
@@ -720,6 +874,7 @@
|
|||||||
const liveLlmTime = document.getElementById('liveLlmTime');
|
const liveLlmTime = document.getElementById('liveLlmTime');
|
||||||
const liveEta = document.getElementById('liveEta');
|
const liveEta = document.getElementById('liveEta');
|
||||||
let currentLiveFile = '';
|
let currentLiveFile = '';
|
||||||
|
let currentStage = 'Обработка: подготовка';
|
||||||
liveLlm.classList.remove('show');
|
liveLlm.classList.remove('show');
|
||||||
liveEta.textContent = '';
|
liveEta.textContent = '';
|
||||||
liveTimerEl.textContent = '0.0 с';
|
liveTimerEl.textContent = '0.0 с';
|
||||||
@@ -730,7 +885,7 @@
|
|||||||
if (ptimer) clearInterval(ptimer);
|
if (ptimer) clearInterval(ptimer);
|
||||||
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 = currentStage + ' · ' + sec + ' с';
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
if (liveRefresh) clearInterval(liveRefresh);
|
if (liveRefresh) clearInterval(liveRefresh);
|
||||||
@@ -739,106 +894,147 @@
|
|||||||
liveTimerEl.textContent = sec + ' с';
|
liveTimerEl.textContent = sec + ' с';
|
||||||
}, 200);
|
}, 200);
|
||||||
|
|
||||||
let procExtractDone = false;
|
function updateStage(label, d, detail = '') {
|
||||||
const fileTimers = {};
|
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 {
|
try {
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
activeES = new EventSource('/api/process_stream/' + currentSid);
|
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 d = JSON.parse(e.data);
|
||||||
const idx = d.idx;
|
setEventFileStatus(d, 'Извлечение текста');
|
||||||
const curRows = document.querySelectorAll('.tree-row.tree-file');
|
setCurrentRow(d.source_idxs?.[0] ?? d.idx);
|
||||||
if (curRows[idx]) {
|
updateStage('Извлечение текста', d);
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
activeES.addEventListener('extract_done', function (e) {
|
activeES.addEventListener('extract_done', function (e) {
|
||||||
procExtractDone = true;
|
const d = JSON.parse(e.data);
|
||||||
const curRows = document.querySelectorAll('.tree-row.tree-file');
|
setEventFileStatus(d, 'Текст извлечён, ожидает анализа');
|
||||||
curRows.forEach(r => r.classList.remove('row-current'));
|
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) {
|
activeES.addEventListener('file_start', function (e) {
|
||||||
const d = JSON.parse(e.data);
|
const d = JSON.parse(e.data);
|
||||||
const idx = d.idx;
|
setEventFileStatus(d, 'Анализ текста');
|
||||||
fileTimers[idx] = performance.now();
|
setCurrentRow(d.source_idxs?.[0] ?? d.idx);
|
||||||
const curRows = document.querySelectorAll('.tree-row.tree-file');
|
liveEta.textContent = '';
|
||||||
if (curRows[idx]) {
|
updateStage('Анализ текста', d);
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
activeES.addEventListener('file_chunk', function (e) {
|
activeES.addEventListener('file_chunk', function (e) {
|
||||||
const d = JSON.parse(e.data);
|
const d = JSON.parse(e.data);
|
||||||
liveFileEl.textContent = 'Файл ' + (d.idx + 1) + '/' + total + ': ' + d.name +
|
const detail = `фрагмент ${d.chunks_done}/${d.chunks_total}`;
|
||||||
(typeof d.eta_sec === 'number' ? ' — осталось ~' + fmtSec(d.eta_sec) : '');
|
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) {
|
activeES.addEventListener('file_done', function (e) {
|
||||||
const d = JSON.parse(e.data);
|
const d = JSON.parse(e.data);
|
||||||
const idx = d.idx;
|
setEventFileStatus(d, 'Анализ завершён, ожидает замены');
|
||||||
const curRows = document.querySelectorAll('.tree-row.tree-file');
|
setCurrentRow(-1);
|
||||||
if (curRows[idx]) {
|
updateStage('Анализ текста', d, 'завершён');
|
||||||
curRows[idx].classList.remove('row-current');
|
});
|
||||||
const td = curRows[idx].querySelectorAll('td')[2];
|
activeES.addEventListener('replace_start', function (e) {
|
||||||
if (td) td.innerHTML = '<span style="color:var(--green)">готов</span>';
|
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) {
|
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');
|
||||||
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);
|
if (typeof d.eta_sec === 'number' && d.eta_sec >= 0) liveEta.textContent = '⏳ осталось ~' + fmtSec(d.eta_sec);
|
||||||
else liveEta.textContent = '';
|
else liveEta.textContent = '';
|
||||||
} else {
|
} else {
|
||||||
liveLlm.classList.remove('show');
|
liveLlm.classList.remove('show');
|
||||||
if (currentLiveFile) liveFileEl.textContent = currentLiveFile;
|
if (currentLiveFile) liveFileEl.textContent = currentStage;
|
||||||
}
|
|
||||||
});
|
|
||||||
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>';
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
activeES.addEventListener('cancelled', function (e) {
|
activeES.addEventListener('cancelled', function (e) {
|
||||||
const d = JSON.parse(e.data);
|
const d = JSON.parse(e.data);
|
||||||
|
markUnfinished('Остановлено до завершения', 'cancelled');
|
||||||
finishProcUI();
|
finishProcUI();
|
||||||
const totalSec = ((performance.now() - t0) / 1000).toFixed(1);
|
const totalSec = ((performance.now() - t0) / 1000).toFixed(1);
|
||||||
st.className = 'status done';
|
const counts = getProcessingCounts();
|
||||||
st.textContent = 'Остановлено. Сохранено ' + d.saved + ' из ' + d.total + ' файлов + таблица замен (общее ' + totalSec + 'с)';
|
st.className = 'status cancelled';
|
||||||
const sb = document.getElementById('statsBlock');
|
st.textContent = `Остановлено: готово ${counts.completed} из ${files.length}, дубликатов ${counts.duplicates}, ошибок ${counts.errors}, остановлено ${counts.stopped}; сохранено ${formatFileCount(d.saved)} · ${totalSec} с`;
|
||||||
document.getElementById('stTotalTime').textContent = totalSec + ' с';
|
showStats(d, 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');
|
db.classList.add('show');
|
||||||
sessionDone = true;
|
sessionDone = true;
|
||||||
ub.disabled = true;
|
ub.disabled = true;
|
||||||
@@ -846,21 +1042,14 @@
|
|||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
activeES.addEventListener('complete', function (e) {
|
activeES.addEventListener('complete', function (e) {
|
||||||
finishProcUI();
|
|
||||||
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';
|
markUnfinished('Не обработан: статус результата не получен', 'error');
|
||||||
st.textContent = 'Обработано ' + d.total + ' файлов: общее ' + totalSec + 'с, из них ИИ ' + (d.llm_sec > 0 ? d.llm_sec + 'с' : '0с');
|
finishProcUI();
|
||||||
const sb = document.getElementById('statsBlock');
|
const counts = getProcessingCounts();
|
||||||
document.getElementById('stTotalTime').textContent = totalSec + ' с';
|
st.className = counts.errors ? 'status error' : 'status done';
|
||||||
if (d.llm_sec > 0) {
|
st.textContent = `Готово: ${counts.completed} из ${files.length}, дубликатов: ${counts.duplicates}, ошибок: ${counts.errors} · ${totalSec} с`;
|
||||||
document.getElementById('stLlmTime').textContent = d.llm_sec + ' с';
|
showStats(d, totalSec);
|
||||||
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');
|
db.classList.add('show');
|
||||||
sessionDone = true;
|
sessionDone = true;
|
||||||
ub.disabled = true;
|
ub.disabled = true;
|
||||||
@@ -869,6 +1058,7 @@
|
|||||||
});
|
});
|
||||||
activeES.addEventListener('proc_error', function (e) {
|
activeES.addEventListener('proc_error', function (e) {
|
||||||
const d = JSON.parse(e.data);
|
const d = JSON.parse(e.data);
|
||||||
|
markUnfinished('Не завершён: ошибка обработки', 'error');
|
||||||
finishProcUI();
|
finishProcUI();
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
sessionDone = false;
|
sessionDone = false;
|
||||||
@@ -882,6 +1072,7 @@
|
|||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
activeES.onerror = function () {
|
activeES.onerror = function () {
|
||||||
|
markUnfinished('Не завершён: потеряно соединение', 'error');
|
||||||
finishProcUI();
|
finishProcUI();
|
||||||
reject(new Error('SSE connection failed'));
|
reject(new Error('SSE connection failed'));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ function renderNode(node, depth, cfg) {
|
|||||||
+ `aria-expanded="${node.expanded}"><span class="tree-chevron">${node.expanded ? '▼' : '▶'}</span>${name}</button>`
|
+ `aria-expanded="${node.expanded}"><span class="tree-chevron">${node.expanded ? '▼' : '▶'}</span>${name}</button>`
|
||||||
: `<span class="tree-name" style="padding-left:${padding}ch">${name}</span>`;
|
: `<span class="tree-name" style="padding-left:${padding}ch">${name}</span>`;
|
||||||
const remove = `<button class="remove-btn" type="button" data-remove="${node.id}" aria-label="${esc(cfg.labels.remove)} ${name}">×</button>`;
|
const remove = `<button class="remove-btn" type="button" data-remove="${node.id}" aria-label="${esc(cfg.labels.remove)} ${name}">×</button>`;
|
||||||
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 = {
|
const cells = {
|
||||||
path: action,
|
path: action,
|
||||||
size: node.kind === 'file' ? fs(node.file.size) : '',
|
size: node.kind === 'file' ? fs(node.file.size) : '',
|
||||||
@@ -55,7 +56,7 @@ function renderNode(node, depth, cfg) {
|
|||||||
const columns = cfg.layout.columns?.length
|
const columns = cfg.layout.columns?.length
|
||||||
? cfg.layout.columns
|
? cfg.layout.columns
|
||||||
: Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
|
: Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
|
||||||
const row = `<tr class="tree-row tree-${node.kind}"${pathAttribute}>`
|
const row = `<tr class="tree-row tree-${node.kind}"${pathAttribute}${fileIdAttribute}>`
|
||||||
+ columns.map((column) => `<td>${cells[column] || ''}</td>`).join('')
|
+ columns.map((column) => `<td>${cells[column] || ''}</td>`).join('')
|
||||||
+ `<td>${remove}</td></tr>`;
|
+ `<td>${remove}</td></tr>`;
|
||||||
if (!isGroup || !node.expanded) return row;
|
if (!isGroup || !node.expanded) return row;
|
||||||
@@ -113,11 +114,11 @@ export function findNode(nodes, id) {
|
|||||||
*
|
*
|
||||||
* @param {Array} nodes Узлы любого уровня.
|
* @param {Array} nodes Узлы любого уровня.
|
||||||
* @param {Array} result Внутренний аккумулятор рекурсивного вызова.
|
* @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 = []) {
|
export function flattenFiles(nodes, result = []) {
|
||||||
nodes.forEach((node) => {
|
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);
|
else flattenFiles(node.children, result);
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
Reference in New Issue
Block a user