v0.0.38: живой таймер обработки + индикация ИИ (live-блок, heartbeat llm в SSE)
Deploy drhider / validate (push) Canceled after 0s
Deploy drhider / validate (push) Canceled after 0s
This commit is contained in:
@@ -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
@@ -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
@@ -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():
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user