Files
LLM-infa/web/views/main_view.py
jze9 2697e01714 Рабочий пайплайн: YouTube -> транскрипция -> выжимка -> Postgres
- новый 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>
2026-07-14 11:12:46 +05:00

407 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)")