- новый api/: /pipeline/process (yt-dlp, субтитры или Vosk, LLM/экстрактивная суммаризация), /videos, /llm; старый код перенесён в api_legacy/ - web/: Flet UI (flet 0.28.3 + flet-web, порт 8550) - Dockerfile.api: ffmpeg слоем из mwader/static-ffmpeg, pip с кэш-маунтом - requirements/pyproject: убраны moviepy, imageio-ffmpeg, битый asyncio - README с инструкцией запуска и загрузкой Vosk-модели Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
407 lines
16 KiB
Python
407 lines
16 KiB
Python
"""Main page of the LLM-infa Flet web app."""
|
||
from __future__ import annotations
|
||
|
||
import threading
|
||
|
||
import flet as ft
|
||
|
||
from web.api_client import ApiClient, ApiError
|
||
import web.designer as d
|
||
|
||
|
||
class MainView:
|
||
def __init__(self, page: ft.Page, client: ApiClient):
|
||
self.page = page
|
||
self.client = client
|
||
|
||
self.models: list[dict] = []
|
||
self.selected_model: str = ""
|
||
|
||
# ── LLM config fields ──────────────────────────────────────────────
|
||
self.base_url = d.text_field("Base URL", hint="https://api.openai.com/v1", expand=True)
|
||
self.api_key = d.text_field("API Key", hint="sk-…", password=True, expand=True)
|
||
|
||
# Searchable model combobox
|
||
self.model_input = d.text_field(
|
||
"Модель",
|
||
hint="Сначала нажмите «Загрузить модели»",
|
||
on_change=self._on_model_search,
|
||
expand=True,
|
||
)
|
||
self._model_list_col = ft.Column(spacing=2, scroll=ft.ScrollMode.AUTO)
|
||
self._model_list_wrap = ft.Container(
|
||
content=self._model_list_col,
|
||
height=260,
|
||
bgcolor=d.SURFACE_3,
|
||
border=ft.border.all(1, d.BORDER),
|
||
border_radius=8,
|
||
padding=4,
|
||
visible=False,
|
||
)
|
||
|
||
self.llm_msg = ft.Text("", color=d.MUTED, size=13, expand=True)
|
||
self.llm_status_chip = ft.Container(
|
||
content=ft.Text("LLM не подключена", size=12, color=d.MUTED),
|
||
padding=ft.padding.symmetric(horizontal=10, vertical=4),
|
||
border=ft.border.all(1, d.BORDER),
|
||
border_radius=99,
|
||
)
|
||
self.btn_load = d.primary_button("Загрузить модели", self._load_models)
|
||
self.btn_test = d.secondary_button("Проверить связь", self._test_conn)
|
||
|
||
# ── Pipeline fields ────────────────────────────────────────────────
|
||
self.yt_url = d.text_field(
|
||
"Ссылка на YouTube",
|
||
hint="https://youtu.be/…",
|
||
expand=True,
|
||
)
|
||
self.vosk_path = d.text_field(
|
||
"Vosk-модель (fallback, когда нет субтитров)",
|
||
value="models/vosk-model-small-ru-0.22",
|
||
expand=True,
|
||
)
|
||
self.n_sentences = ft.TextField(
|
||
label="Предложений в выжимке",
|
||
value="15",
|
||
input_filter=ft.NumbersOnlyInputFilter(),
|
||
bgcolor=d.SURFACE_3,
|
||
color=d.TEXT,
|
||
border_color=d.BORDER,
|
||
focused_border_color=d.ACCENT,
|
||
label_style=ft.TextStyle(color=d.MUTED, size=12),
|
||
text_size=14,
|
||
width=220,
|
||
)
|
||
self.pipeline_msg = ft.Text("", color=d.MUTED, size=13, expand=True)
|
||
self.btn_run = d.primary_button("▶ Запустить", self._run_pipeline)
|
||
|
||
# Result block (initially hidden)
|
||
self._result_col = ft.Column(spacing=6, visible=False)
|
||
|
||
# Library
|
||
self._library_col = ft.Column(spacing=8)
|
||
|
||
# ─────────────────────── build ───────────────────────────────────────
|
||
|
||
def build(self) -> ft.Control:
|
||
return ft.Column(
|
||
controls=[
|
||
self._build_topbar(),
|
||
ft.Container(
|
||
content=ft.Column(
|
||
[
|
||
self._build_llm_card(),
|
||
self._build_pipeline_card(),
|
||
self._build_library_card(),
|
||
],
|
||
spacing=20,
|
||
scroll=ft.ScrollMode.AUTO,
|
||
),
|
||
padding=ft.padding.symmetric(horizontal=24, vertical=20),
|
||
expand=True,
|
||
),
|
||
],
|
||
expand=True,
|
||
spacing=0,
|
||
)
|
||
|
||
def _build_topbar(self) -> ft.Container:
|
||
return ft.Container(
|
||
content=ft.Row(
|
||
[
|
||
ft.Row(
|
||
[
|
||
ft.Container(
|
||
width=14,
|
||
height=14,
|
||
border_radius=4,
|
||
gradient=ft.LinearGradient(
|
||
begin=ft.alignment.top_left,
|
||
end=ft.alignment.bottom_right,
|
||
colors=[d.ACCENT, "#5b8def"],
|
||
),
|
||
),
|
||
ft.Text("LLM-infa", color=d.TEXT, size=17, weight=ft.FontWeight.W_600),
|
||
],
|
||
spacing=10,
|
||
),
|
||
ft.Container(expand=True),
|
||
self.llm_status_chip,
|
||
],
|
||
),
|
||
padding=ft.padding.symmetric(horizontal=24, vertical=14),
|
||
bgcolor=ft.Colors.with_opacity(0.6, d.SURFACE),
|
||
border=ft.border.only(bottom=ft.BorderSide(1, d.BORDER)),
|
||
)
|
||
|
||
def _build_llm_card(self) -> ft.Container:
|
||
return d.card(
|
||
d.section_title("Подключение к LLM"),
|
||
d.hint(
|
||
"Укажите любой OpenAI-совместимый провайдер. "
|
||
"Нейронка внутри Docker не разворачивается — "
|
||
"указываем URL внешнего сервиса."
|
||
),
|
||
ft.Row([self.base_url, self.api_key], spacing=12),
|
||
ft.Row([self.btn_load, self.btn_test, self.llm_msg], spacing=10),
|
||
ft.Divider(color=d.BORDER, height=1),
|
||
self.model_input,
|
||
self._model_list_wrap,
|
||
)
|
||
|
||
def _build_pipeline_card(self) -> ft.Container:
|
||
return d.card(
|
||
d.section_title("Обработать видео"),
|
||
d.hint(
|
||
"Pipeline: yt-dlp → субтитры (или Vosk fallback) "
|
||
"→ выжимка через выбранную LLM → запись в БД."
|
||
),
|
||
self.yt_url,
|
||
ft.Row([self.vosk_path, self.n_sentences], spacing=12),
|
||
ft.Row([self.btn_run, self.pipeline_msg], spacing=10),
|
||
self._result_col,
|
||
)
|
||
|
||
def _build_library_card(self) -> ft.Container:
|
||
return d.card(
|
||
ft.Row(
|
||
[
|
||
d.section_title("Библиотека"),
|
||
ft.Container(expand=True),
|
||
d.secondary_button("Обновить", lambda _: self._refresh_library()),
|
||
]
|
||
),
|
||
d.hint("Все обработанные видео и пути к файлам."),
|
||
self._library_col,
|
||
)
|
||
|
||
# ─────────────────── model combobox ──────────────────────────────────
|
||
|
||
def _on_model_search(self, e):
|
||
q = (self.model_input.value or "").strip().lower()
|
||
filtered = [m for m in self.models if q in m["id"].lower()] if q else self.models
|
||
self._render_model_list(filtered)
|
||
|
||
def _render_model_list(self, items: list[dict]):
|
||
self._model_list_col.controls = [
|
||
ft.Container(
|
||
content=ft.Row(
|
||
[
|
||
ft.Text(m["id"], color=d.TEXT, size=13, expand=True, no_wrap=True),
|
||
ft.Text(m.get("owned_by", ""), color=d.MUTED, size=11),
|
||
],
|
||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||
),
|
||
ink=True,
|
||
bgcolor=d.SURFACE_2,
|
||
padding=ft.padding.symmetric(horizontal=10, vertical=8),
|
||
border_radius=6,
|
||
on_click=lambda e, mid=m["id"]: self._select_model(mid),
|
||
)
|
||
for m in items
|
||
]
|
||
if not items:
|
||
self._model_list_col.controls = [
|
||
ft.Text("Ничего не найдено", color=d.MUTED, size=13)
|
||
]
|
||
self._model_list_wrap.visible = bool(self.models)
|
||
self.page.update()
|
||
|
||
def _select_model(self, mid: str):
|
||
self.selected_model = mid
|
||
self.model_input.value = mid
|
||
self._model_list_wrap.visible = False
|
||
self.page.update()
|
||
|
||
# ─────────────────── LLM actions ─────────────────────────────────────
|
||
|
||
def _load_models(self, _):
|
||
base_url = self.base_url.value.strip()
|
||
if not base_url:
|
||
self._set_llm_msg("Укажите Base URL", error=True)
|
||
return
|
||
|
||
self._set_llm_msg("Загружаю…")
|
||
self.btn_load.disabled = True
|
||
self.page.update()
|
||
|
||
def _task():
|
||
try:
|
||
self.models = self.client.llm_models(base_url, self.api_key.value.strip())
|
||
self._render_model_list(self.models)
|
||
self._set_llm_msg(f"Загружено: {len(self.models)} моделей", ok=True)
|
||
self._set_status(ok=True)
|
||
except ApiError as e:
|
||
self._set_llm_msg(str(e), error=True)
|
||
self._set_status(ok=False)
|
||
finally:
|
||
self.btn_load.disabled = False
|
||
self.page.update()
|
||
|
||
threading.Thread(target=_task, daemon=True).start()
|
||
|
||
def _test_conn(self, _):
|
||
if not self.selected_model:
|
||
self._set_llm_msg("Сначала выберите модель", error=True)
|
||
return
|
||
|
||
self._set_llm_msg("Проверяю…")
|
||
self.btn_test.disabled = True
|
||
self.page.update()
|
||
|
||
def _task():
|
||
try:
|
||
r = self.client.llm_test(
|
||
self.base_url.value.strip(),
|
||
self.api_key.value.strip(),
|
||
self.selected_model,
|
||
)
|
||
preview = (r.get("reply") or "")[:80]
|
||
self._set_llm_msg(f"OK · {preview}", ok=True)
|
||
self._set_status(ok=True)
|
||
except ApiError as e:
|
||
self._set_llm_msg(str(e), error=True)
|
||
self._set_status(ok=False)
|
||
finally:
|
||
self.btn_test.disabled = False
|
||
self.page.update()
|
||
|
||
threading.Thread(target=_task, daemon=True).start()
|
||
|
||
# ─────────────────── pipeline ─────────────────────────────────────────
|
||
|
||
def _run_pipeline(self, _):
|
||
url = self.yt_url.value.strip()
|
||
if not url:
|
||
self._set_pipeline_msg("Укажите ссылку на видео", error=True)
|
||
return
|
||
|
||
payload: dict = {
|
||
"url": url,
|
||
"model_path": self.vosk_path.value.strip() or None,
|
||
"summary_max_sentences": int(self.n_sentences.value or 15),
|
||
}
|
||
if self.base_url.value.strip() and self.selected_model:
|
||
payload["llm"] = {
|
||
"base_url": self.base_url.value.strip(),
|
||
"api_key": self.api_key.value.strip(),
|
||
"model": self.selected_model,
|
||
}
|
||
|
||
self._set_pipeline_msg("Обработка видео… (может занять несколько минут)")
|
||
self._result_col.visible = False
|
||
self.btn_run.disabled = True
|
||
self.page.update()
|
||
|
||
def _task():
|
||
try:
|
||
r = self.client.pipeline_process(payload)
|
||
self._render_result(r)
|
||
self._set_pipeline_msg("Готово ✓", ok=True)
|
||
self._refresh_library()
|
||
except ApiError as e:
|
||
self._set_pipeline_msg(str(e), error=True)
|
||
finally:
|
||
self.btn_run.disabled = False
|
||
self.page.update()
|
||
|
||
threading.Thread(target=_task, daemon=True).start()
|
||
|
||
def _render_result(self, r: dict):
|
||
self._result_col.controls = [
|
||
ft.Divider(color=d.BORDER, height=1),
|
||
ft.Text(r.get("title") or r.get("uuid", ""), color=d.ACCENT_2, size=15, weight=ft.FontWeight.W_600),
|
||
*[
|
||
self._kv(k, v)
|
||
for k, v in [
|
||
("UUID", r.get("uuid", "")),
|
||
("Источник", r.get("source_url", "")),
|
||
("Метод", r.get("transcription_method", "")),
|
||
("Видео", r.get("video_path", "")),
|
||
("Полный текст", r.get("text_full_path", "")),
|
||
("Выжимка", r.get("text_summary_path", "")),
|
||
]
|
||
],
|
||
ft.Text("Превью выжимки:", color=d.MUTED, size=12),
|
||
ft.Container(
|
||
content=ft.Text(r.get("summary_preview", ""), color=d.TEXT, size=13, selectable=True, no_wrap=False),
|
||
padding=12,
|
||
bgcolor=d.SURFACE_3,
|
||
border=ft.border.all(1, d.BORDER),
|
||
border_radius=8,
|
||
),
|
||
]
|
||
self._result_col.visible = True
|
||
|
||
# ─────────────────── library ─────────────────────────────────────────
|
||
|
||
def _refresh_library(self):
|
||
try:
|
||
items = self.client.list_videos()
|
||
except ApiError as e:
|
||
self._library_col.controls = [ft.Text(str(e), color=d.DANGER, size=13)]
|
||
self.page.update()
|
||
return
|
||
|
||
if not items:
|
||
self._library_col.controls = [ft.Text("Пока пусто", color=d.MUTED, size=13)]
|
||
else:
|
||
self._library_col.controls = [self._lib_card(v) for v in items]
|
||
self.page.update()
|
||
|
||
def _lib_card(self, v: dict) -> ft.Container:
|
||
created = v.get("created_at", "")[:19].replace("T", " ")
|
||
return ft.Container(
|
||
content=ft.Column(
|
||
[
|
||
ft.Text(
|
||
v.get("title") or v.get("uuid", ""),
|
||
color=d.TEXT,
|
||
size=14,
|
||
weight=ft.FontWeight.W_600,
|
||
no_wrap=True,
|
||
),
|
||
ft.Text(v.get("source_url", ""), color=d.MUTED, size=11, selectable=True),
|
||
ft.Row(
|
||
[
|
||
ft.Text(f"uuid: {v.get('uuid', '')}", color=d.MUTED, size=11, selectable=True, expand=True),
|
||
ft.Text(created, color=d.MUTED, size=11),
|
||
]
|
||
),
|
||
self._kv("Метод", v.get("transcription_method", "")),
|
||
self._kv("Видео", v.get("video_path", "")),
|
||
self._kv("Выжимка", v.get("text_summary_path", "")),
|
||
],
|
||
spacing=3,
|
||
tight=True,
|
||
),
|
||
padding=12,
|
||
bgcolor=d.SURFACE_2,
|
||
border=ft.border.all(1, d.BORDER),
|
||
border_radius=10,
|
||
)
|
||
|
||
# ─────────────────── helpers ──────────────────────────────────────────
|
||
|
||
def _kv(self, key: str, value: str) -> ft.Row:
|
||
return ft.Row(
|
||
[
|
||
ft.Text(key, color=d.MUTED, size=12, width=120),
|
||
ft.Text(value, color=d.TEXT, size=12, selectable=True, expand=True, no_wrap=False),
|
||
],
|
||
)
|
||
|
||
def _set_llm_msg(self, text: str, *, ok: bool = False, error: bool = False):
|
||
self.llm_msg.value = text
|
||
self.llm_msg.color = d.OK if ok else (d.DANGER if error else d.MUTED)
|
||
|
||
def _set_pipeline_msg(self, text: str, *, ok: bool = False, error: bool = False):
|
||
self.pipeline_msg.value = text
|
||
self.pipeline_msg.color = d.OK if ok else (d.DANGER if error else d.MUTED)
|
||
|
||
def _set_status(self, *, ok: bool):
|
||
chip = self.llm_status_chip
|
||
chip.content.value = "LLM подключена" if ok else "Ошибка подключения"
|
||
chip.content.color = d.OK if ok else d.DANGER
|
||
chip.border = ft.border.all(1, "rgba(109,213,140,.4)" if ok else "rgba(227,90,114,.4)")
|