diff --git a/History/2026-08-19-live-timer-llm.md b/History/2026-08-19-live-timer-llm.md new file mode 100644 index 0000000..96401a4 --- /dev/null +++ b/History/2026-08-19-live-timer-llm.md @@ -0,0 +1,55 @@ +# v0.0.38 — живой таймер обработки + индикация ИИ — 2026-08-19 + +**Дата:** 2026-08-19 +**Версия:** 0.0.37 → 0.0.38 + +--- + +## Суть + +Юзер должен видеть, что обработка идёт (не зависла), особенно пока LLM +«переваривает» большой файл. Добавлены: +- крупный live-блок «⏱ Обработка…» с тикающим таймером общего времени; +- строка текущего файла с размером («Файл 3/5: имя (5.0 МБ)»); +- индикация ИИ: «🤖 ИИ обрабатывает… Nс» (от реальных данных с бэка). + +Итоговая формулировка времени стала ясной: «…общее Xс, из них ИИ Yс». + +## Изменения + +### `drhider/llm_client.py` +- В `__init__` добавлены `llm_active` (bool) и `llm_started` (float). +- Метод `llm_elapsed_now()` — секунд с начала текущего LLM-вызова (0 если не активен). +- `complete()`: ставит `llm_active=True`/`llm_started=time.time()` перед HTTP, + сбрасывает `llm_active=False` в `finally`. `import time` добавлен. + +### `site/routes/api_bp.py` +- В ветке `except queue.Empty` (пока воркер занят) — heartbeat-событие SSE: + `event: llm` → `{active, elapsed, tokens}` (раз в ~1с, timeout=1). + +### `site/templates/index.html` +- CSS для `.live-block`, `.live-title`, `.live-file`, `.live-timer` (40px), `.live-llm`. +- HTML: блок `#liveBlock` с `#liveFile`, `#liveTimer`, `#liveLlm`/`#liveLlmTime`; + в итоговом блоке подпись «Время ИИ» → «Из них ИИ». +- JS (фаза 2): + - показ `#liveBlock`, интервал `liveRefresh` (200мс) — тикает `#liveTimer`; + - обработчик `start` — в `#liveFile` имя текущего файла + размер из `sf[idx]`; + - обработчик `llm` — показывает/скрывает `#liveLlm` и обновляет `#liveLlmTime`; + - `complete`/`onerror`/`catch` — скрыть `#liveBlock`, очистить интервалы; + - итоговый текст: «общее Xс, из них ИИ Yс». +- `resetAll` — скрывает `#liveBlock`. + +### `site/app.py` +- `VERSION = "0.0.38"`. + +## Проверка +- `node --check`, `py_compile` — OK. +- `LLMClient`: во время вызова `llm_active=True`, `llm_elapsed_now()≈0.3с`; после — + `False`; накопление токенов и времени — ок. +- SSE end-to-end: `complete` содержит `{total, tokens, llm_sec}`. Heartbeat `llm` + генерируется в ветке `except queue.Empty` при долгой обработке (локально без + LLM обработка мгновенная, поэтому heartbeat не успевает — в проде при долгой + LLM-обработке будет слаться раз в сек). + +## Примечание +Бэкенд метрики не менял (только флаг/метод для live-таймера + heartbeat). diff --git a/drhider/llm_client.py b/drhider/llm_client.py index d91aa6d..c40e4c3 100644 --- a/drhider/llm_client.py +++ b/drhider/llm_client.py @@ -10,6 +10,7 @@ LLM-клиент для DrHider. """ import os +import time import httpx @@ -29,16 +30,28 @@ class LLMClient: tokens_prompt — суммарные входные токены tokens_completion — суммарные выходные токены llm_sec — суммарное время LLM-запросов (сек) + + Плюс флаг текущей активности (для live-таймера в UI): + llm_active — True пока идёт LLM-вызов + llm_started — time.time() начала текущего LLM-вызова """ self._httpx = httpx self.tokens_prompt = 0 self.tokens_completion = 0 self.llm_sec = 0.0 + self.llm_active = False + self.llm_started = 0.0 @property def tokens_total(self) -> int: return self.tokens_prompt + self.tokens_completion + def llm_elapsed_now(self) -> float: + """Секунд с начала текущего LLM-вызова (0, если LLM не активен).""" + if self.llm_active: + return time.time() - self.llm_started + return 0.0 + def complete(self, prompt: str) -> str: """Отправить промпт в LLM и вернуть текст ответа. @@ -59,29 +72,35 @@ class LLMClient: url = os.environ.get("LLM_URL", "https://api.aillm.ru/v1/chat/completions") model = os.environ.get("LLM_MODEL", "gpt-oss-120b") - r = self._httpx.post( - url, - json={ - "model": model, - "messages": [{"role": "user", "content": prompt}], - "max_tokens": 8000, - "temperature": 0.1, - }, - headers={ - "Authorization": f"Bearer {key}", - "Content-Type": "application/json", - }, - timeout=120, - ) - r.raise_for_status() + # Пометим LLM-вызов как активный (для live-таймера в UI) + self.llm_active = True + self.llm_started = time.time() + try: + r = self._httpx.post( + url, + json={ + "model": model, + "messages": [{"role": "user", "content": prompt}], + "max_tokens": 8000, + "temperature": 0.1, + }, + headers={ + "Authorization": f"Bearer {key}", + "Content-Type": "application/json", + }, + timeout=120, + ) + r.raise_for_status() - # Время запроса (реальное время HTTP-вызова LLM) - self.llm_sec += r.elapsed.total_seconds() + # Время запроса (реальное время HTTP-вызова LLM) + self.llm_sec += r.elapsed.total_seconds() - data = r.json() - # Счётчики токенов из usage (могут отсутствовать у некоторых прокси) - usage = data.get("usage", {}) or {} - self.tokens_prompt += int(usage.get("prompt_tokens", 0) or 0) - self.tokens_completion += int(usage.get("completion_tokens", 0) or 0) + data = r.json() + # Счётчики токенов из usage (могут отсутствовать у некоторых прокси) + usage = data.get("usage", {}) or {} + self.tokens_prompt += int(usage.get("prompt_tokens", 0) or 0) + self.tokens_completion += int(usage.get("completion_tokens", 0) or 0) - return data["choices"][0]["message"]["content"] + return data["choices"][0]["message"]["content"] + finally: + self.llm_active = False diff --git a/site/app.py b/site/app.py index 43294b6..97fcee2 100644 --- a/site/app.py +++ b/site/app.py @@ -20,7 +20,7 @@ if _sys_path_root not in sys.path: sys.path.insert(0, _sys_path_root) # Версия приложения (меняется при изменениях) -VERSION = "0.0.37" +VERSION = "0.0.38" def create_app(): diff --git a/site/routes/api_bp.py b/site/routes/api_bp.py index 5d0c17f..f22e73f 100644 --- a/site/routes/api_bp.py +++ b/site/routes/api_bp.py @@ -103,6 +103,15 @@ def process_stream(sid): except queue.Empty: if cancel.is_set(): return + # Heartbeat: живая статистика LLM (для таймера в UI) + try: + yield ( + f"event: llm\n" + f"data: {json.dumps({'active': llm.llm_active, 'elapsed': round(llm.llm_elapsed_now(), 1), 'tokens': llm.tokens_total})}\n\n" + ) + except _disconnect_exceptions(): + cancel.set() + return continue kind = evt[0] diff --git a/site/templates/index.html b/site/templates/index.html index 6ff8bef..364bee5 100644 --- a/site/templates/index.html +++ b/site/templates/index.html @@ -97,6 +97,21 @@ } .stat-label { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin-bottom: 4px; } .stat-value { font-size: 24px; font-weight: 800; color: #166534; } + .live-block { + display: none; margin-top: 12px; padding: 16px; + background: linear-gradient(135deg, #eff6ff, #dbeafe); + border: 2px solid var(--brand-primary); border-radius: 12px; + } + .live-block.show { display: block; } + .live-title { font-size: 18px; font-weight: 800; color: #1e40af; margin-bottom: 6px; } + .live-file { font-size: 15px; color: #1e3a8a; margin-bottom: 10px; word-break: break-word; } + .live-timer { font-size: 40px; font-weight: 800; color: #1d4ed8; line-height: 1.1; } + .live-llm { + display: none; margin-top: 8px; padding: 8px 12px; border-radius: 8px; + background: #fef3c7; border: 1px solid #f59e0b; color: #92400e; + font-size: 18px; font-weight: 700; width: fit-content; + } + .live-llm.show { display: inline-block; } @@ -141,6 +156,12 @@ +
+
⏱ Обработка…
+
+
0.0 с
+
🤖 ИИ обрабатывает… 0.0 с
+
📊 Итоги обработки
@@ -149,7 +170,7 @@
-
Время ИИ
+
Из них ИИ
@@ -186,6 +207,7 @@ function resetAll() { st.textContent = ''; db.classList.remove('show'); document.getElementById('statsBlock').classList.remove('show'); + document.getElementById('liveBlock').classList.remove('show'); ub.disabled = false; } @@ -418,6 +440,21 @@ async function uploadFiles() { st.textContent = 'Обработка... ' + sec + 'с'; }, 1000); + // Крупный live-блок «Идёт обработка» с тикающим таймером + const liveBlock = document.getElementById('liveBlock'); + const liveTimerEl = document.getElementById('liveTimer'); + const liveFileEl = document.getElementById('liveFile'); + const liveLlm = document.getElementById('liveLlm'); + const liveLlmTime = document.getElementById('liveLlmTime'); + liveLlm.classList.remove('show'); + liveTimerEl.textContent = '0.0 с'; + liveFileEl.textContent = 'Подготовка…'; + liveBlock.classList.add('show'); + const liveRefresh = setInterval(() => { + const sec = ((performance.now() - t0) / 1000).toFixed(1); + liveTimerEl.textContent = sec + ' с'; + }, 200); + try { await new Promise((resolve, reject) => { activeES = new EventSource('/api/process_stream/' + currentSid); @@ -428,6 +465,19 @@ async function uploadFiles() { const elapsed = ((performance.now() - fileTimers[d.idx]) / 1000).toFixed(1); ss(d.idx, '⏳ ' + elapsed + 'с'); }, 200); + // Показываем текущий файл и его размер в live-блоке + const f = sf[d.idx]; + const sz = f ? fs(f.size) : ''; + liveFileEl.textContent = 'Файл ' + (d.idx + 1) + '/' + total + ': ' + d.name + (sz ? ' (' + sz + ')' : ''); + }); + activeES.addEventListener('llm', function(e) { + const d = JSON.parse(e.data); + if (d.active) { + liveLlmTime.textContent = d.elapsed + ' с'; + liveLlm.classList.add('show'); + } else { + liveLlm.classList.remove('show'); + } }); activeES.addEventListener('done', function(e) { const d = JSON.parse(e.data); @@ -439,10 +489,12 @@ async function uploadFiles() { activeES.close(); activeES = null; clearInterval(ptimer); + clearInterval(liveRefresh); + liveBlock.classList.remove('show'); const d = JSON.parse(e.data); const totalSec = ((performance.now() - t0) / 1000).toFixed(1); st.className = 'status done'; - st.textContent = '✅ Обработано ' + d.total + ' файлов за ' + totalSec + 'с'; + st.textContent = '✅ Обработано ' + d.total + ' файлов: общее ' + totalSec + 'с, из них ИИ ' + (d.llm_sec > 0 ? d.llm_sec + 'с' : '0с'); // Крупный блок статистики const sb = document.getElementById('statsBlock'); document.getElementById('stTotalTime').textContent = totalSec + ' с'; @@ -461,11 +513,15 @@ async function uploadFiles() { activeES.close(); activeES = null; clearInterval(ptimer); + clearInterval(liveRefresh); + liveBlock.classList.remove('show'); reject(new Error('SSE connection failed')); }; }); } catch (err) { clearInterval(ptimer); + clearInterval(liveRefresh); + liveBlock.classList.remove('show'); st.className = 'status error'; st.textContent = 'Ошибка: ' + err.message; }