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 @@ +