"""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)")