v0.0.38: живой таймер обработки + индикация ИИ (live-блок, heartbeat llm в SSE)
Deploy drhider / validate (push) Canceled after 0s

This commit is contained in:
“Naeel”
2026-08-19 09:25:01 +04:00
parent defbc10004
commit e548c89880
5 changed files with 165 additions and 26 deletions
+55
View File
@@ -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).
+42 -23
View File
@@ -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
+1 -1
View File
@@ -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():
+9
View File
@@ -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]
+58 -2
View File
@@ -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; }
</style>
</head>
<body>
@@ -141,6 +156,12 @@
<button class="btn btn-primary" id="dlZip" onclick="downloadZip()">📦 Скачать ZIP</button>
<button class="btn" id="dlCsv" onclick="downloadCsv()">📋 Скачать CSV</button>
</div>
<div class="live-block" id="liveBlock">
<div class="live-title">⏱ Обработка…</div>
<div class="live-file" id="liveFile"></div>
<div class="live-timer" id="liveTimer">0.0 с</div>
<div class="live-llm" id="liveLlm">🤖 ИИ обрабатывает… <span id="liveLlmTime">0.0 с</span></div>
</div>
<div class="stats-block" id="statsBlock">
<div class="stats-title">📊 Итоги обработки</div>
<div class="stats-row">
@@ -149,7 +170,7 @@
<div class="stat-value" id="stTotalTime"></div>
</div>
<div class="stat-item">
<div class="stat-label">Время ИИ</div>
<div class="stat-label">Из них ИИ</div>
<div class="stat-value" id="stLlmTime"></div>
</div>
<div class="stat-item">
@@ -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, '<span style="color:#2563eb">⏳ ' + elapsed + 'с</span>');
}, 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;
}